feat(v5): add dashboard ingress routing controls

Add V5 dashboard controls and validation for enabling server-backed app ingress, track generic ingress type/status fields, and improve Flux dispatch timeout/error handling.
This commit is contained in:
Andras Bacsai
2026-06-20 23:10:48 +02:00
parent ad0bcc8a4a
commit 1c091e9e36
22 changed files with 1120 additions and 287 deletions
@@ -136,7 +136,8 @@ class ApplyFluxResourceStatusUpdate
}
$server->update([
'caddy_ingress_status' => $status,
'ingress_type' => 'caddy',
'ingress_status' => $status,
'last_status_check' => 'flux',
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
'last_status_checked_at' => now(),
@@ -35,8 +35,6 @@ class GenerateCaddyIngressConfiguration
'restart' => 'unless-stopped',
'ports' => [
'80:80',
'443:443',
'443:443/udp',
],
'volumes' => [
'./Caddyfile:/etc/caddy/Caddyfile:ro',
@@ -102,7 +100,7 @@ CADDY;
$upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$application->internal_port}";
return implode("\n", [
"{$domain->domain} {",
"http://{$domain->domain} {",
" reverse_proxy {$upstream}",
'}',
]);
+4 -1
View File
@@ -30,7 +30,10 @@ class StartCaddyIngress
$output = $this->fluxClient->applyCaddyIngress($hostId, $configuration['caddyfile'], $configuration['apps']);
if ($server->exists) {
$server->update(['caddy_ingress_status' => 'running']);
$server->update([
'ingress_type' => 'caddy',
'ingress_status' => 'running',
]);
}
return $output;
+1 -1
View File
@@ -23,7 +23,7 @@ class StopCaddyIngress
$output = $this->fluxClient->stopCaddyIngress($hostId);
if ($server->exists) {
$server->update(['caddy_ingress_status' => 'exited']);
$server->update(['ingress_status' => 'exited']);
}
return $output;
+2 -1
View File
@@ -82,7 +82,8 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->caddyIngressStatus(),
'type' => $server->ingressType(),
'status' => $server->ingressStatus(),
'canvasX' => $server->canvas_x ?? -352,
'canvasY' => $server->canvas_y ?? 0,
];
@@ -19,6 +19,7 @@ use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Rules\ValidHostname;
use App\Services\Flux\FluxClient;
use App\Services\Flux\FluxHealth;
use Illuminate\Database\Eloquent\Builder;
@@ -336,7 +337,8 @@ class DashboardController extends Controller
: 'exited';
$server->update([
'caddy_ingress_status' => $state,
'ingress_type' => 'caddy',
'ingress_status' => $state,
'last_status_check' => 'flux',
'last_status_output' => 'Caddy ingress state refreshed from coold.',
'last_status_checked_at' => now(),
@@ -389,10 +391,18 @@ class DashboardController extends Controller
$validated = $request->validate([
'ingress_enabled' => ['required', 'boolean'],
'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'domains' => ['sometimes', 'array'],
'domains.*' => ['required', 'string', 'max:255', 'distinct'],
'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'],
'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname],
]);
$application->loadMissing('server');
if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) {
return response()->json([
'message' => 'Enable ingress on the server before enabling app ingress.',
], 422);
}
DB::transaction(function () use ($application, $validated): void {
$application->update([
'ingress_enabled' => $validated['ingress_enabled'],
@@ -416,7 +426,11 @@ class DashboardController extends Controller
$application->refresh()->load(['server', 'domains']);
if ($application->server?->isIngress() && $application->server->status === 'installed') {
StartCaddyIngress::run($application->server);
try {
StartCaddyIngress::run($application->server);
} catch (\RuntimeException $exception) {
return $this->ingressSyncErrorResponse($exception);
}
}
return response()->json([
@@ -742,10 +756,17 @@ class DashboardController extends Controller
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'],
'ingress_enabled' => ['sometimes', 'boolean'],
'ingress_type' => [
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
'nullable',
'string',
Rule::in(['caddy']),
],
]);
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false);
$ingressType = $ingressEnabled ? $validated['ingress_type'] : null;
$builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity);
$builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota;
$devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']);
@@ -760,6 +781,7 @@ class DashboardController extends Controller
'ssh_port' => $validated['ssh_port'],
'private_key_id' => $validated['private_key_id'] ?? null,
'status' => 'added',
'ingress_type' => $ingressType,
'capabilities' => $this->serverCapabilities($builderEnabled, $ingressEnabled),
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
@@ -800,22 +822,35 @@ class DashboardController extends Controller
),
'builder_cpu_quota' => ['required', 'string', 'max:32'],
'ingress_enabled' => ['sometimes', 'boolean'],
'ingress_type' => [
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
'nullable',
'string',
Rule::in(['caddy']),
],
]);
$wasIngress = $server->isIngress();
$builderEnabled = (bool) $validated['builder_enabled'];
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress);
$ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null;
$capabilities = $this->serverCapabilities($builderEnabled, $ingressEnabled);
$server->update([
'capabilities' => $capabilities,
'ingress_type' => $ingressType,
'builder_enabled' => $builderEnabled,
'builder_capacity' => (int) $validated['builder_capacity'],
'builder_cpu_quota' => $validated['builder_cpu_quota'],
]);
$server->refresh();
$this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled);
try {
$this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled);
} catch (\RuntimeException $exception) {
return $this->ingressSyncErrorResponse($exception);
}
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
@@ -1273,7 +1308,8 @@ class DashboardController extends Controller
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->caddyIngressStatus(),
'type' => $server->ingressType(),
'status' => $server->ingressStatus(),
'canvasX' => $server->canvas_x ?? -self::CANVAS_CARD_WIDTH - self::CANVAS_CARD_GAP,
'canvasY' => $server->canvas_y ?? $index * (self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP),
];
@@ -1424,6 +1460,7 @@ class DashboardController extends Controller
'statusMessage' => $application->status_message,
'runtimeContainerId' => $application->runtime_container_id,
'serverName' => $application->server?->name,
'serverIngressEnabled' => (bool) $application->server?->isIngress(),
'meshNamespace' => $application->mesh_namespace,
'ingressEnabled' => $application->ingress_enabled,
'internalPort' => $application->internal_port,
@@ -1506,6 +1543,37 @@ class DashboardController extends Controller
}
}
private function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse
{
return response()->json([
'message' => $this->friendlyIngressSyncError($exception->getMessage()),
'detail' => $exception->getMessage(),
], 502);
}
private function friendlyIngressSyncError(string $message): string
{
$normalized = Str::lower($message);
if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) {
return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.';
}
if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) {
return 'coold did not respond in time. Check that the server agent is running and connected to Flux.';
}
if (str_contains($normalized, 'validate caddyfile')) {
return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.';
}
if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) {
return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.';
}
return 'Could not update ingress. Check Flux and coold logs, then try again.';
}
/**
* @return array<string, mixed>
*/
@@ -1546,6 +1614,7 @@ class DashboardController extends Controller
'builderCapacity' => $server->builder_capacity,
'builderCpuQuota' => $server->builder_cpu_quota,
'ingressEnabled' => $server->isIngress(),
'ingressType' => $server->ingress_type,
'uuid' => $server->uuid,
'nodeAddress' => $server->node_address,
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
+15 -5
View File
@@ -24,7 +24,8 @@ class Server extends V5Model
'ssh_user',
'ssh_port',
'status',
'caddy_ingress_status',
'ingress_type',
'ingress_status',
'capabilities',
'builder_enabled',
'builder_capacity',
@@ -50,7 +51,11 @@ class Server extends V5Model
protected static function booted(): void
{
static::updated(function (self $server): void {
if (! $server->wasChanged('status') && ! $server->wasChanged('caddy_ingress_status')) {
if (
! $server->wasChanged('status')
&& ! $server->wasChanged('ingress_type')
&& ! $server->wasChanged('ingress_status')
) {
return;
}
@@ -111,15 +116,20 @@ class Server extends V5Model
return $this->hasCapability('ingress');
}
public function caddyIngressStatus(): string
public function ingressStatus(): string
{
if ($this->caddy_ingress_status !== null) {
return $this->caddy_ingress_status;
if ($this->ingress_status !== null) {
return $this->ingress_status;
}
return $this->status === 'installed' ? 'running' : 'unknown';
}
public function ingressType(): string
{
return $this->ingress_type ?? 'caddy';
}
public function cluster(): BelongsTo
{
return $this->belongsTo(Cluster::class);
+41 -8
View File
@@ -66,14 +66,15 @@ class FluxClient
'request_id' => (string) Str::uuid(),
'command' => $command,
], JSON_THROW_ON_ERROR);
$timeout = (float) config('flux.health_timeout_seconds', 1.0);
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout);
$connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0);
$dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0);
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout);
if ($stream === false) {
throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
}
stream_set_timeout($stream, (int) ceil($timeout));
stream_set_timeout($stream, (int) ceil($dispatchTimeout));
fwrite($stream, implode("\r\n", [
'POST /v1/coold/dispatch HTTP/1.1',
@@ -89,12 +90,13 @@ class FluxClient
$response = stream_get_contents($stream) ?: '';
fclose($stream);
if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) {
throw new RuntimeException('Flux dispatch did not return HTTP 200.');
}
$statusCode = $this->statusCode($response);
$responseBody = $this->responseBody($response);
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
$responseBody = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : '';
$payload = json_decode($responseBody, true);
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException($this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}.");
}
if (! is_array($payload)) {
throw new RuntimeException('Flux dispatch returned an invalid response.');
@@ -109,6 +111,37 @@ class FluxClient
return $payload;
}
private function statusCode(string $response): int
{
if ($response === '') {
throw new RuntimeException('Flux did not return a response before the timeout. Check that coold is connected to Flux and try again.');
}
if (preg_match('/^HTTP\/\d(?:\.\d)?\s+(\d{3})/', $response, $matches) !== 1) {
throw new RuntimeException('Could not talk to Flux. Check that Flux is running in the Coolify container.');
}
return (int) $matches[1];
}
private function responseBody(string $response): string
{
$position = strpos($response, "\r\n\r\n");
return $position === false ? '' : substr($response, $position + 4);
}
private function errorMessage(mixed $payload, string $responseBody): ?string
{
if (is_array($payload) && is_string($payload['message'] ?? null) && $payload['message'] !== '') {
return $payload['message'];
}
$message = trim($responseBody);
return $message === '' ? null : Str::limit($message, 1000);
}
/**
* @param array<string, mixed> $payload
*/
+2
View File
@@ -5,5 +5,7 @@ return [
'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')),
'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')),
'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0),
'connection_timeout_seconds' => (float) env('COOLIFY_FLUX_CONNECTION_TIMEOUT_SECONDS', 1.0),
'dispatch_timeout_seconds' => (float) env('COOLIFY_FLUX_DISPATCH_TIMEOUT_SECONDS', 35.0),
'laravel_api_token' => env('COOLIFY_FLUX_LARAVEL_API_TOKEN'),
];
@@ -23,6 +23,8 @@ return new class extends Migration
$table->string('ssh_user');
$table->unsignedInteger('ssh_port')->default(22);
$table->string('status')->default('installed');
$table->string('ingress_type')->nullable();
$table->string('ingress_status')->nullable();
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
@@ -33,6 +35,8 @@ return new class extends Migration
$table->string('wireguard_management_ip')->nullable();
$table->string('wireguard_public_key')->nullable();
$table->json('container_subnets')->nullable();
$table->integer('canvas_x')->nullable();
$table->integer('canvas_y')->nullable();
$table->timestamp('last_bootstrapped_at')->nullable();
$table->string('last_bootstrap_action')->nullable();
$table->string('last_bootstrap_status')->nullable();
@@ -25,6 +25,8 @@ return new class extends Migration
$table->text('status_message')->nullable();
$table->string('runtime_container_id')->nullable();
$table->string('mesh_namespace')->default('default');
$table->boolean('ingress_enabled')->default(false);
$table->unsignedSmallInteger('internal_port')->nullable();
$table->integer('canvas_x')->default(0);
$table->integer('canvas_y')->default(0);
$table->timestamps();
@@ -33,6 +35,15 @@ return new class extends Migration
$table->index(['team_id', 'project_id', 'environment_id']);
$table->index(['team_id', 'server_id']);
});
Schema::create('v5_application_domains', function (Blueprint $table) {
$table->id();
$table->foreignId('application_id')->constrained('v5_applications')->cascadeOnDelete();
$table->string('domain');
$table->timestamps();
$table->unique(['application_id', 'domain']);
});
}
/**
@@ -40,6 +51,7 @@ return new class extends Migration
*/
public function down(): void
{
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
}
};
@@ -1,29 +0,0 @@
<?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('v5_servers', function (Blueprint $table) {
$table->integer('canvas_x')->nullable()->after('container_subnets');
$table->integer('canvas_y')->nullable()->after('canvas_x');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn(['canvas_x', 'canvas_y']);
});
}
};
@@ -1,38 +0,0 @@
<?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('v5_servers', function (Blueprint $table) {
$table->string('caddy_ingress_status')->nullable()->after('status');
});
DB::table('v5_servers')
->where('status', 'installed')
->where(function ($query) {
$query
->where('capabilities', 'like', '%"ingress"%')
->orWhere('capabilities', 'like', '%ingress%');
})
->update(['caddy_ingress_status' => 'running']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn('caddy_ingress_status');
});
}
};
@@ -1,40 +0,0 @@
<?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('v5_applications', function (Blueprint $table) {
$table->boolean('ingress_enabled')->default(false);
$table->unsignedSmallInteger('internal_port')->nullable();
});
Schema::create('v5_application_domains', function (Blueprint $table) {
$table->id();
$table->foreignId('application_id')->constrained('v5_applications')->cascadeOnDelete();
$table->string('domain');
$table->timestamps();
$table->unique(['application_id', 'domain']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_application_domains');
Schema::table('v5_applications', function (Blueprint $table) {
$table->dropColumn(['ingress_enabled', 'internal_port']);
});
}
};
+34 -5
View File
@@ -1363,7 +1363,8 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"ssh_user" TEXT NOT NULL,
"ssh_port" INTEGER DEFAULT '22' NOT NULL,
"status" TEXT DEFAULT 'installed' NOT NULL,
"caddy_ingress_status" TEXT,
"ingress_type" TEXT,
"ingress_status" TEXT,
"capabilities" TEXT,
"builder_enabled" INTEGER DEFAULT false NOT NULL,
"builder_capacity" INTEGER DEFAULT '0' NOT NULL,
@@ -1432,6 +1433,34 @@ CREATE TABLE IF NOT EXISTS "v5_application_domains" (
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_resource_connections" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL,
"environment_id" INTEGER NOT NULL,
"resource_one_type" TEXT NOT NULL,
"resource_one_id" INTEGER NOT NULL,
"resource_two_type" TEXT NOT NULL,
"resource_two_id" INTEGER NOT NULL,
"resource_pair_key" TEXT NOT NULL,
"created_by_user_id" INTEGER NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"connection_id" INTEGER NOT NULL,
"source_resource_type" TEXT NOT NULL,
"source_resource_id" INTEGER NOT NULL,
"target_resource_type" TEXT NOT NULL,
"target_resource_id" INTEGER NOT NULL,
"protocol" TEXT DEFAULT 'tcp' NOT NULL,
"port" INTEGER NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "webhook_notification_settings" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
@@ -1548,6 +1577,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_uniq
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_applications_container_name_unique" ON "v5_applications" (container_name);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_application_domains_application_id_domain_unique" ON "v5_application_domains" (application_id, domain);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connections_team_id_resource_pair_key_unique" ON "v5_resource_connections" (team_id, resource_pair_key);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connection_rules_unique_direction_port" ON "v5_resource_connection_rules" (connection_id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, protocol, port);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid);
CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id);
@@ -1869,7 +1900,5 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_0
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_141231_add_canvas_position_to_v5_servers_table', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table', 320);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (321, '2026_06_19_182231_create_container_statuses_table', 321);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (322, '2026_06_20_072818_v5_add_ingress_routing_to_applications_table', 322);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_142000_v5_create_resource_connections_table', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_182231_create_container_statuses_table', 320);
+177 -71
View File
@@ -64,6 +64,7 @@ type ServerFormErrors = {
builder_enabled?: string[];
builder_capacity?: string[];
builder_cpu_quota?: string[];
ingress_type?: string[];
wireguard_listen_port_override?: string[];
wireguard_endpoint_override?: string[];
};
@@ -139,6 +140,13 @@ const clusterDefaults = {
builderTimeoutSecs: '1800',
};
const ingressTypes = [
{
label: 'Caddy',
value: 'caddy',
},
];
function formatDate(value: string | null): string {
if (value === null) {
return 'Never';
@@ -188,6 +196,7 @@ export default function Clusters({
const [serverNodeAddress, setServerNodeAddress] = useState('');
const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true);
const [serverIngressEnabled, setServerIngressEnabled] = useState(false);
const [serverIngressType, setServerIngressType] = useState('caddy');
const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2');
const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState('');
@@ -196,6 +205,7 @@ export default function Clusters({
const [editingServer, setEditingServer] = useState<V5Server | null>(null);
const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true);
const [editServerIngressEnabled, setEditServerIngressEnabled] = useState(false);
const [editServerIngressType, setEditServerIngressType] = useState('caddy');
const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2');
const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [editServerErrors, setEditServerErrors] = useState<ServerFormErrors>({});
@@ -422,6 +432,7 @@ export default function Clusters({
node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress,
builder_enabled: serverBuilderEnabled,
ingress_enabled: serverIngressEnabled,
ingress_type: serverIngressEnabled ? serverIngressType : null,
builder_capacity: Number(serverBuilderCapacity),
builder_cpu_quota: serverBuilderCpuQuota,
wireguard_listen_port_override:
@@ -480,6 +491,7 @@ export default function Clusters({
body: JSON.stringify({
builder_enabled: editServerBuilderEnabled,
ingress_enabled: editServerIngressEnabled,
ingress_type: editServerIngressEnabled ? editServerIngressType : null,
builder_capacity: Number(editServerBuilderCapacity),
builder_cpu_quota: editServerBuilderCpuQuota,
}),
@@ -496,8 +508,10 @@ export default function Clusters({
}
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
setEditServerErrors({
builder_capacity: ['Unable to update this server. Please try again.'],
builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'],
});
setIsServerUpdateSubmitting(false);
@@ -629,6 +643,7 @@ export default function Clusters({
setEditingServer(server);
setEditServerBuilderEnabled(server.builderEnabled);
setEditServerIngressEnabled(server.ingressEnabled);
setEditServerIngressType(server.ingressType ?? 'caddy');
setEditServerBuilderCapacity(String(server.builderCapacity));
setEditServerBuilderCpuQuota(server.builderCpuQuota);
setEditServerErrors({});
@@ -721,6 +736,8 @@ export default function Clusters({
setSelectedPrivateKeyId('');
setServerNodeAddress('');
setServerBuilderEnabled(selectedCluster?.builderEnabled ?? true);
setServerIngressEnabled(false);
setServerIngressType('caddy');
setServerBuilderCapacity(String(selectedCluster?.builderCapacity ?? 2));
setServerBuilderCpuQuota(selectedCluster?.builderCpuQuota ?? clusterDefaults.builderCpuQuota);
setWireguardListenPortOverride('');
@@ -733,6 +750,7 @@ export default function Clusters({
setEditingServer(null);
setEditServerBuilderEnabled(true);
setEditServerIngressEnabled(false);
setEditServerIngressType('caddy');
setEditServerBuilderCapacity('2');
setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota);
setEditServerErrors({});
@@ -1586,53 +1604,104 @@ export default function Clusters({
<FieldError message={serverErrors.node_address?.[0]} />
</Field>
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={serverBuilderCapacity}
onChange={(event) =>
setServerBuilderCapacity(event.target.value)
}
inputMode="numeric"
aria-invalid={serverErrors.builder_capacity ? true : undefined}
/>
<FieldError message={serverErrors.builder_capacity?.[0]} />
</Field>
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverBuilderEnabled}
onChange={(event) =>
setServerBuilderEnabled(event.target.checked)
}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={serverBuilderCpuQuota}
onChange={(event) =>
setServerBuilderCpuQuota(event.target.value)
}
placeholder="200%"
aria-invalid={serverErrors.builder_cpu_quota ? true : undefined}
/>
<FieldError message={serverErrors.builder_cpu_quota?.[0]} />
</Field>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={serverBuilderCapacity}
onChange={(event) =>
setServerBuilderCapacity(event.target.value)
}
inputMode="numeric"
aria-invalid={
serverErrors.builder_capacity ? true : undefined
}
/>
<FieldError message={serverErrors.builder_capacity?.[0]} />
</Field>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverBuilderEnabled}
onChange={(event) =>
setServerBuilderEnabled(event.target.checked)
}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={serverBuilderCpuQuota}
onChange={(event) =>
setServerBuilderCpuQuota(event.target.value)
}
placeholder="200%"
aria-invalid={
serverErrors.builder_cpu_quota ? true : undefined
}
/>
<FieldError message={serverErrors.builder_cpu_quota?.[0]} />
</Field>
</div>
</section>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverIngressEnabled}
onChange={(event) =>
setServerIngressEnabled(event.target.checked)
}
/>
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
</Field>
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverIngressEnabled}
onChange={(event) =>
setServerIngressEnabled(event.target.checked)
}
/>
<FieldLabel>Enable ingress on this server</FieldLabel>
</Field>
<Field className="mt-4">
<FieldLabel>Ingress type</FieldLabel>
<Select
items={ingressTypes}
value={serverIngressType}
onValueChange={(value) => {
if (value !== null) {
setServerIngressType(value);
}
}}
disabled={!serverIngressEnabled}
>
<SelectTrigger
aria-label="Select ingress type"
className="h-10 w-full rounded-md px-3 text-sm"
aria-invalid={
serverErrors.ingress_type ? true : undefined
}
>
<SelectValue placeholder="Select ingress type" />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
sideOffset={4}
>
<SelectGroup>
{ingressTypes.map((ingressType) => (
<SelectItem
key={ingressType.value}
value={ingressType.value}
>
{ingressType.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError message={serverErrors.ingress_type?.[0]} />
</Field>
</section>
<Field>
<FieldLabel>WireGuard listen override</FieldLabel>
@@ -1701,13 +1770,13 @@ export default function Clusters({
<DialogHeader>
<DialogTitle>Edit server</DialogTitle>
<DialogDescription>
Update builder scheduling limits and Caddy ingress for {editingServer?.name ?? 'this server'}.
Update builder scheduling limits and ingress for {editingServer?.name ?? 'this server'}.
Networking and bootstrap settings stay locked after creation.
</DialogDescription>
</DialogHeader>
<form className="mt-5 flex flex-col gap-4" onSubmit={updateServer}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<section className="rounded-lg border border-border bg-muted/20 p-4">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
@@ -1717,39 +1786,76 @@ export default function Clusters({
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={editServerBuilderCapacity}
onChange={(event) => setEditServerBuilderCapacity(event.target.value)}
inputMode="numeric"
aria-invalid={editServerErrors.builder_capacity ? true : undefined}
/>
<FieldError message={editServerErrors.builder_capacity?.[0]} />
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={editServerBuilderCpuQuota}
onChange={(event) => setEditServerBuilderCpuQuota(event.target.value)}
placeholder="200%"
aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined}
/>
<FieldError message={editServerErrors.builder_cpu_quota?.[0]} />
</Field>
</div>
</section>
<section className="rounded-lg border border-border bg-muted/20 p-4">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerIngressEnabled}
onChange={(event) => setEditServerIngressEnabled(event.target.checked)}
/>
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Builder capacity</FieldLabel>
<Input
value={editServerBuilderCapacity}
onChange={(event) => setEditServerBuilderCapacity(event.target.value)}
inputMode="numeric"
aria-invalid={editServerErrors.builder_capacity ? true : undefined}
/>
<FieldError message={editServerErrors.builder_capacity?.[0]} />
<FieldLabel>Enable ingress on this server</FieldLabel>
</Field>
<Field>
<FieldLabel>Builder CPU quota</FieldLabel>
<Input
value={editServerBuilderCpuQuota}
onChange={(event) => setEditServerBuilderCpuQuota(event.target.value)}
placeholder="200%"
aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined}
/>
<FieldError message={editServerErrors.builder_cpu_quota?.[0]} />
<Field className="mt-4">
<FieldLabel>Ingress type</FieldLabel>
<Select
items={ingressTypes}
value={editServerIngressType}
onValueChange={(value) => {
if (value !== null) {
setEditServerIngressType(value);
}
}}
disabled={!editServerIngressEnabled}
>
<SelectTrigger
aria-label="Select ingress type"
className="h-10 w-full rounded-md px-3 text-sm"
aria-invalid={editServerErrors.ingress_type ? true : undefined}
>
<SelectValue placeholder="Select ingress type" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{ingressTypes.map((ingressType) => (
<SelectItem
key={ingressType.value}
value={ingressType.value}
>
{ingressType.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError message={editServerErrors.ingress_type?.[0]} />
</Field>
</div>
</section>
<p className="text-xs text-muted-foreground">
Host, bootstrap credentials, node address, and WireGuard overrides are not
+202 -33
View File
@@ -2,6 +2,11 @@ import { Head } from '@inertiajs/react';
import { useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, type WheelEvent } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
import { csrfToken } from '@/lib/csrf';
import { cn } from '@/lib/utils';
@@ -25,6 +30,13 @@ type V5CanvasResourceUpdatedEvent = {
caddyIngress: V5CaddyIngress | null;
};
type IngressModalState = {
application: V5Application;
domains: string;
internalPort: string;
error: string | null;
};
type EchoChannel = {
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
subscribed?: (callback: () => void) => EchoChannel;
@@ -148,6 +160,9 @@ export default function Dashboard({
const [selectedNginxServerId, setSelectedNginxServerId] = useState<string>(nginxServers[0]?.id ?? '');
const [isRefreshing, setIsRefreshing] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
const [isSavingIngress, setIsSavingIngress] = useState(false);
const [savingIngressApplicationId, setSavingIngressApplicationId] = useState<string | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
@@ -649,34 +664,69 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
}
}
async function updateApplicationIngress(application: V5Application, enabled: boolean): Promise<void> {
function openApplicationIngressModal(application: V5Application): void {
setNotice(null);
const domains = enabled
? window
.prompt('Domains for this app, separated by commas', application.domains.join(', '))
?.split(',')
.map((domain) => domain.trim())
.filter(Boolean)
: application.domains;
if (enabled && (!domains || domains.length === 0)) {
setNotice('Add at least one domain before enabling app ingress.');
if (!application.serverIngressEnabled) {
setNotice('Enable ingress on the server before enabling app ingress.');
return;
}
const internalPort = enabled
? Number(window.prompt('Internal container port', String(application.internalPort ?? '')))
: application.internalPort;
setIngressModal({
application,
domains: application.domains.join(', '),
internalPort: application.internalPort ? String(application.internalPort) : '',
error: null,
});
}
if (enabled && (!Number.isInteger(internalPort) || Number(internalPort) < 1 || Number(internalPort) > 65535)) {
setNotice('Choose a valid internal port before enabling app ingress.');
async function disableApplicationIngress(application: V5Application): Promise<void> {
await saveApplicationIngress(application, false, application.domains, application.internalPort);
}
async function submitApplicationIngress(): Promise<void> {
if (!ingressModal) {
return;
}
const domains = ingressModal.domains
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
const internalPort = Number(ingressModal.internalPort);
const invalidDomain = domains.find((domain) => !isValidDomain(domain));
if (domains.length === 0) {
setIngressModal({ ...ingressModal, error: 'Add at least one valid domain.' });
return;
}
const selectedInternalPort = enabled ? Number(internalPort) : application.internalPort;
if (invalidDomain) {
setIngressModal({ ...ingressModal, error: `${invalidDomain} is not a valid domain.` });
return;
}
if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) {
setIngressModal({ ...ingressModal, error: 'Choose a valid internal port between 1 and 65535.' });
return;
}
await saveApplicationIngress(ingressModal.application, true, [...new Set(domains)], internalPort);
}
async function saveApplicationIngress(
application: V5Application,
enabled: boolean,
domains: string[],
internalPort: number | null,
): Promise<void> {
setNotice(null);
setIsSavingIngress(true);
setSavingIngressApplicationId(application.id);
try {
const response = await fetch(`/v5/applications/${application.id}/ingress`, {
@@ -689,13 +739,20 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
},
body: JSON.stringify({
ingress_enabled: enabled,
internal_port: selectedInternalPort,
internal_port: internalPort,
domains,
}),
});
if (!response.ok) {
setNotice('Could not update application ingress.');
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
const message = payload?.message ?? 'Could not update application ingress.';
if (ingressModal) {
setIngressModal({ ...ingressModal, error: message });
} else {
setNotice(message);
}
return;
}
@@ -706,11 +763,63 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
candidate.id === payload.application.id ? payload.application : candidate,
),
);
setIngressModal(null);
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not update application ingress.');
const message = error instanceof Error ? error.message : 'Could not update application ingress.';
if (ingressModal) {
setIngressModal({ ...ingressModal, error: message });
} else {
setNotice(message);
}
} finally {
setIsSavingIngress(false);
setSavingIngressApplicationId(null);
}
}
function isValidDomain(domain: string): boolean {
if (domain.length < 1 || domain.length > 253 || domain.startsWith('.') || domain.endsWith('.')) {
return false;
}
return domain.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
}
function renderIngressButton(application: V5Application) {
const isDisabled = !application.ingressEnabled && !application.serverIngressEnabled;
const isApplicationIngressSaving = savingIngressApplicationId === application.id;
const button = (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
disabled={isDisabled || isApplicationIngressSaving}
onClick={(event) => {
event.stopPropagation();
application.ingressEnabled
? void disableApplicationIngress(application)
: openApplicationIngressModal(application);
}}
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{isApplicationIngressSaving ? 'Saving...' : application.ingressEnabled ? 'Disable' : 'Enable'}
</button>
);
if (!isDisabled) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>You need to enable ingress in server settings first.</p>
</TooltipContent>
</Tooltip>
);
}
async function addNginx(): Promise<void> {
setIsCreating(true);
setNotice(null);
@@ -1091,7 +1200,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
}
return (
<>
<TooltipProvider>
<Head title="Dashboard" />
<div className="h-dvh overflow-hidden bg-background text-foreground">
@@ -1552,17 +1661,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'}${application.internalPort ?? 'no port'}`
: 'Private'}
</span>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void updateApplicationIngress(application, !application.ingressEnabled);
}}
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted"
>
{application.ingressEnabled ? 'Disable' : 'Enable'}
</button>
{renderIngressButton(application)}
</dd>
</div>
</dl>
@@ -1572,6 +1671,76 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
</div>
</main>
</div>
</>
{ingressModal && (
<Dialog
open
onOpenChange={(open) => {
if (!open && !isSavingIngress) {
setIngressModal(null);
}
}}
>
<DialogContent className="max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle>Enable app ingress</DialogTitle>
<DialogDescription>
Route public domains to {ingressModal.application.name} through the server ingress.
</DialogDescription>
</DialogHeader>
<form
className="mt-6 flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submitApplicationIngress();
}}
>
<Field>
<FieldLabel>Domains</FieldLabel>
<Input
type="text"
value={ingressModal.domains}
onChange={(event) =>
setIngressModal({ ...ingressModal, domains: event.target.value, error: null })
}
placeholder="example.com, www.example.com"
/>
<span className="text-xs text-muted-foreground">
Use hostnames only, separated by commas. No scheme, path, wildcard, or port.
</span>
</Field>
<Field>
<FieldLabel>Internal port</FieldLabel>
<Input
type="number"
min="1"
max="65535"
value={ingressModal.internalPort}
onChange={(event) =>
setIngressModal({ ...ingressModal, internalPort: event.target.value, error: null })
}
placeholder="3000"
/>
</Field>
{ingressModal.error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<p className="font-medium">Ingress update failed</p>
<p className="mt-1 text-destructive/90">{ingressModal.error}</p>
</div>
)}
<div className="flex justify-end">
<Button type="submit" variant="coolify" disabled={isSavingIngress}>
{isSavingIngress ? 'Saving...' : 'Enable ingress'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)}
</TooltipProvider>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-none border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-none data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-none bg-popover fill-popover data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+3
View File
@@ -9,12 +9,14 @@ export type V5Server = {
id: string;
name: string;
host: string;
type: string;
status: string;
capabilities: string[];
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
ingressEnabled: boolean;
ingressType: string | null;
uuid: string | null;
nodeAddress: string | null;
wireguardListenPortOverride: number | null;
@@ -90,6 +92,7 @@ export type V5Application = {
statusMessage: string | null;
runtimeContainerId: string | null;
serverName: string | null;
serverIngressEnabled: boolean;
meshNamespace: string;
ingressEnabled: boolean;
internalPort: number | null;
@@ -7,3 +7,10 @@ it('does not render server capability summaries on the cluster page', function (
->not->toContain('Capabilities:')
->not->toContain('normalizeCapabilities');
});
it('shows server update error messages returned by the v5 api', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain("payload?.message ?? 'Unable to update this server. Please try again.'");
});
+383 -42
View File
@@ -120,6 +120,94 @@ it('does not render v5 application status messages on dashboard cards', function
->not->toContain('{application.statusMessage}</p>');
});
it('uses the shared dialog and button components for the application ingress modal', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardSource)
->toContain('<Dialog')
->toContain('open')
->toContain('<DialogContent')
->toContain('showCloseButton')
->toContain('<Button type="submit" variant="coolify"')
->not->toContain('>Cancel</button>')
->not->toContain('>Close</button>');
});
it('shows a loading state while toggling application ingress', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardSource)
->toContain('savingIngressApplicationId')
->toContain("isApplicationIngressSaving ? 'Saving...'")
->toContain("isSavingIngress ? 'Saving...' : 'Enable ingress'");
});
it('generates http-only caddy routes for application ingress', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->with(
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
&& ! str_contains($apps[0]['caddyfile'], 'https://')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
)
->andReturn('Caddy ingress applied.');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertSuccessful();
});
it('shows a dashboard refresh button next to the center button', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
@@ -345,7 +433,7 @@ it('creates v5 server tables in the shared database', function () {
]))->toBeTrue();
});
it('adds v5 server canvas columns for movable caddy ingress nodes', function () {
it('creates v5 server canvas columns for movable caddy ingress nodes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
@@ -357,16 +445,13 @@ it('adds v5 server canvas columns for movable caddy ingress nodes', function ()
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$canvasMigration = include database_path('migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php');
$canvasMigration->up();
expect(Schema::hasColumns('v5_servers', [
'canvas_x',
'canvas_y',
]))->toBeTrue();
});
it('adds v5 server caddy ingress container status column', function () {
it('creates v5 server ingress columns', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
@@ -378,28 +463,16 @@ it('adds v5 server caddy ingress container status column', function () {
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
[$user, $team] = createV5UserWithTeam();
$server = V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-ingress-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
]);
$statusMigration = include database_path('migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php');
$statusMigration->up();
expect(Schema::hasColumn('v5_servers', 'caddy_ingress_status'))->toBeTrue()
->and($server->refresh()->caddy_ingress_status)->toBe('running');
expect(Schema::hasColumns('v5_servers', [
'ingress_type',
'ingress_status',
]))->toBeTrue();
});
it('creates v5 application tables for dashboard canvas nodes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
@@ -428,6 +501,8 @@ it('creates v5 application tables for dashboard canvas nodes', function () {
'status_message',
'runtime_container_id',
'mesh_namespace',
'ingress_enabled',
'internal_port',
'canvas_x',
'canvas_y',
'created_at',
@@ -452,9 +527,6 @@ it('creates v5 application domain tables for zero or more inbound routes', funct
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
$domainMigration = include database_path('migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php');
$domainMigration->up();
expect(Schema::hasTable('v5_application_domains'))->toBeTrue()
->and(Schema::hasColumns('v5_application_domains', [
'id',
@@ -474,6 +546,7 @@ it('creates generic v5 resource connection tables', function () {
Schema::dropIfExists('v5_resource_connection_rules');
Schema::dropIfExists('v5_resource_connections');
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
@@ -534,10 +607,14 @@ it('keeps v5 server fields in the initial migration', function () {
expect(Schema::hasColumns('v5_servers', [
'uuid',
'ingress_type',
'ingress_status',
'builder_cpu_quota',
'node_address',
'wireguard_management_ip',
'container_subnets',
'canvas_x',
'canvas_y',
'last_bootstrap_output',
'last_status_output',
]))->toBeTrue();
@@ -554,6 +631,8 @@ it('includes v5 tables in the dev testing schema', function () {
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_container_statuses"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_applications"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_application_domains"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connections"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules"')
->and($schema)->toContain('"domain" TEXT NOT NULL')
->and($schema)->toContain('"ingress_enabled" INTEGER DEFAULT false NOT NULL')
->and($schema)->toContain('"internal_port" INTEGER')
@@ -564,7 +643,8 @@ it('includes v5 tables in the dev testing schema', function () {
->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL')
->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('"caddy_ingress_status" TEXT')
->and($schema)->toContain('"ingress_type" TEXT')
->and($schema)->toContain('"ingress_status" TEXT')
->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL')
->and($schema)->toContain('"uuid" TEXT')
->and($schema)->toContain('"wireguard_management_ip" TEXT')
@@ -575,10 +655,11 @@ it('includes v5 tables in the dev testing schema', function () {
->and($schema)->toContain('"last_status_output" TEXT')
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
->and($schema)->toContain('2026_06_19_140000_v5_create_applications_table')
->and($schema)->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table')
->and($schema)->toContain('2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table')
->and($schema)->toContain('2026_06_19_142000_v5_create_resource_connections_table')
->and($schema)->toContain('2026_06_19_182231_create_container_statuses_table')
->and($schema)->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_table')
->and($schema)->not->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_173933_add_ingress_status_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_table')
->and($schema)->not->toContain('2026_06_19_150000_add_mesh_namespace_to_v5_applications_table')
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
->and($schema)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
@@ -829,7 +910,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'canvas_x' => -160,
'canvas_y' => 240,
@@ -842,7 +923,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'exited',
'ingress_status' => 'exited',
'capabilities' => ['coold', 'ingress'],
]);
V5Server::query()->create([
@@ -864,6 +945,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
->assertSee('"caddyIngresses":[', false)
->assertSee('"name":"edge-ingress-01"', false)
->assertSee('"host":"203.0.113.20"', false)
->assertSee('"type":"caddy"', false)
->assertSee('"status":"running"', false)
->assertSee('"name":"edge-ingress-02"', false)
->assertSee('"status":"exited"', false)
@@ -1475,7 +1557,7 @@ it('applies flux caddy ingress container status updates without changing server
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
@@ -1492,7 +1574,8 @@ it('applies flux caddy ingress container status updates without changing server
expect($resource)->toBeInstanceOf(V5Server::class)
->and($server->refresh()->status)->toBe('installed')
->and($server->caddy_ingress_status)->toBe('exited')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('exited')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toBe('Caddy container exited.')
->and($server->last_status_checked_at)->not->toBeNull();
@@ -1762,7 +1845,7 @@ it('refreshes v5 caddy ingress state from flux container inventory', function ()
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'wireguard_management_ip' => '100.64.0.6',
]);
@@ -1793,10 +1876,12 @@ it('refreshes v5 caddy ingress state from flux container inventory', function ()
->postJson('/v5/applications/refresh')
->assertSuccessful()
->assertJsonPath('caddyIngresses.0.id', (string) $server->id)
->assertJsonPath('caddyIngresses.0.type', 'caddy')
->assertJsonPath('caddyIngresses.0.status', 'exited');
expect($server->refresh()->status)->toBe('installed')
->and($server->caddy_ingress_status)->toBe('exited')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('exited')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toBe('Caddy ingress state refreshed from coold.');
@@ -2205,6 +2290,7 @@ it('adds a v5 server to a cluster for the current team', function () {
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%')
->assertJsonPath('cluster.servers.0.ingressEnabled', false)
->assertJsonPath('cluster.servers.0.ingressType', null)
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'builder'])
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821')
@@ -2251,14 +2337,17 @@ it('adds a v5 server with caddy ingress enabled', function () {
'builder_enabled' => false,
'builder_capacity' => 0,
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertCreated()
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
$server = V5Server::query()->where('name', 'edge-01')->first();
expect($server->capabilities)->toBe(['coold', 'ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->isIngress())->toBeTrue();
});
@@ -3095,19 +3184,81 @@ it('updates editable v5 server caddy ingress capability independently from build
'builder_capacity' => 2,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
$server->refresh();
expect($server->capabilities)->toBe(['coold', 'ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->builder_enabled)->toBeFalse()
->and($server->isIngress())->toBeTrue();
});
it('rejects application ingress when server ingress is disabled', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'app-01',
'host' => '203.0.113.21',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.11',
'last_bootstrapped_at' => now(),
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'private-app',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-private-app',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('applyCaddyIngress');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertUnprocessable()
->assertJsonPath('message', 'Enable ingress on the server before enabling app ingress.');
expect($application->refresh()->ingress_enabled)->toBeFalse()
->and($application->domains()->count())->toBe(0);
});
it('enables application ingress without publishing domains by default', function () {
createSharedUserAndTeamTables();
@@ -3179,6 +3330,64 @@ it('enables application ingress without publishing domains by default', function
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('validates application ingress domains', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('applyCaddyIngress');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['https://bad.example.com'],
])
->assertUnprocessable()
->assertInvalid(['domains.0']);
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('enables application ingress with explicit domains and port', function () {
createSharedUserAndTeamTables();
@@ -3227,7 +3436,7 @@ it('enables application ingress with explicit domains and port', function () {
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'app.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
)
->andReturn('Caddy ingress applied.');
@@ -3251,6 +3460,66 @@ it('enables application ingress with explicit domains and port', function () {
->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']);
});
it('returns flux error details when application ingress sync fails', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->andThrow(new RuntimeException('start Caddy ingress: podman exited with status 125'));
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertStatus(502)
->assertJsonPath('message', 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.')
->assertJsonPath('detail', 'start Caddy ingress: podman exited with status 125');
});
it('syncs caddy ingress routes through flux when enabling ingress on an installed server', function () {
createSharedUserAndTeamTables();
@@ -3309,8 +3578,8 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'www.nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://www.nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
)
->andReturn('Caddy ingress applied.');
@@ -3324,11 +3593,82 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.ingressEnabled', true);
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy');
expect($server->refresh()->caddy_ingress_status)->toBe('running');
expect($server->refresh()->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('running');
});
it('returns flux error details when server ingress activation fails', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'running',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
V5ApplicationDomain::query()->create([
'application_id' => $application->id,
'domain' => 'nginx.example.com',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->andThrow(new RuntimeException('validate Caddyfile: unrecognized directive'));
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertStatus(502)
->assertJsonPath('message', 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.')
->assertJsonPath('detail', 'validate Caddyfile: unrecognized directive');
});
it('keeps editable v5 server builder capacity when disabling builder', function () {
@@ -3735,7 +4075,7 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain('function csrfToken()')
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain("import { Button } from '@/components/ui/button';")
->toContain("import { Button } from '@/components/ui/button';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
@@ -4517,7 +4857,8 @@ function createSharedUserAndTeamTables(): void
$table->string('ssh_user');
$table->unsignedInteger('ssh_port');
$table->string('status')->default('installed');
$table->string('caddy_ingress_status')->nullable();
$table->string('ingress_type')->nullable();
$table->string('ingress_status')->nullable();
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
@@ -8,6 +8,7 @@ use App\Models\V5\ApplicationDomain;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
uses(TestCase::class);
@@ -34,15 +35,16 @@ it('generates a caddy ingress compose file with health endpoint and application
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
->and($configuration['compose'])->toContain('80:80')
->and($configuration['compose'])->toContain('443:443')
->and($configuration['compose'])->not->toContain('443:443')
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
->and($configuration['compose'])->toContain('./apps:/etc/caddy/apps:ro')
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
->and($configuration['caddyfile'])->toContain('respond 404')
->and($configuration['caddyfile'])->toContain('import apps/*.caddy')
->and($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->toContain('nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->not->toContain('https://')
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
});
@@ -152,3 +154,89 @@ it('stops caddy ingress through flux instead of ssh', function () {
expect($result)->toBe('Caddy ingress stopped.');
});
it('includes flux error response details when dispatch returns a non success status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'start Caddy ingress: podman exited with status 125',
], JSON_THROW_ON_ERROR);
withFakeFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
it('uses a friendly message when flux returns an invalid http response', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
withFakeFluxSocket(
'',
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
it('uses separate flux timeouts for health checks and command dispatches', function () {
expect(config('flux.health_timeout_seconds'))->toBe(1.0)
->and(config('flux.connection_timeout_seconds'))->toBe(1.0)
->and(config('flux.dispatch_timeout_seconds'))->toBe(35.0);
});
function withFakeFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($server, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}