wowowowowo
This commit is contained in:
@@ -63,3 +63,5 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
HETZNER_KEY=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Actions\Applications\VerifyRepositoryAccess;
|
|||||||
use App\Enums\RepositoryType;
|
use App\Enums\RepositoryType;
|
||||||
use App\Enums\ServerStatus;
|
use App\Enums\ServerStatus;
|
||||||
use App\Http\Requests\StoreApplicationRequest;
|
use App\Http\Requests\StoreApplicationRequest;
|
||||||
|
use App\Http\Requests\UpdateApplicationRequest;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -27,9 +28,12 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
return inertia('applications/Create');
|
return inertia('applications/Create', [
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()->get(),
|
||||||
|
'repositoryTypes' => RepositoryType::toArray(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreApplicationRequest $request): RedirectResponse
|
public function store(StoreApplicationRequest $request): RedirectResponse
|
||||||
@@ -38,8 +42,9 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
$application = $organisation->applications()->create([
|
$application = $organisation->applications()->create([
|
||||||
'name' => $request->string('name')->toString(),
|
'name' => $request->string('name')->toString(),
|
||||||
|
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
|
||||||
'repository_url' => $request->string('repository_url')->toString(),
|
'repository_url' => $request->string('repository_url')->toString(),
|
||||||
'repository_type' => RepositoryType::GIT,
|
'repository_type' => $request->enum('repository_type', RepositoryType::class),
|
||||||
'default_branch' => $request->string('default_branch')->toString(),
|
'default_branch' => $request->string('default_branch')->toString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -56,14 +61,24 @@ class ApplicationController extends Controller
|
|||||||
$id = $request->route('application');
|
$id = $request->route('application');
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
$application = Application::with([
|
$application = Application::with([
|
||||||
|
'environments.buildArtifacts' => fn ($query) => $query->latest()->limit(5),
|
||||||
|
'environments.operations' => fn ($query) => $query->latest()->limit(1),
|
||||||
|
'environments.services.replicas',
|
||||||
|
'environments.services.endpoints',
|
||||||
'environments.services.slices',
|
'environments.services.slices',
|
||||||
'environments.attachments.service',
|
'environments.attachments.service',
|
||||||
'environments.variables',
|
'environments.variables',
|
||||||
'organisation',
|
'organisation',
|
||||||
|
'sourceProvider',
|
||||||
])->whereBelongsTo($organisation)->findOrFail($id);
|
])->whereBelongsTo($organisation)->findOrFail($id);
|
||||||
|
|
||||||
return inertia('applications/Show', [
|
return inertia('applications/Show', [
|
||||||
'application' => $application,
|
'application' => $application,
|
||||||
|
'deploymentRequirements' => [
|
||||||
|
'registryRequired' => $organisation->servers()->count() > 1 && $organisation->registries()->doesntExist(),
|
||||||
|
'registryCount' => $organisation->registries()->count(),
|
||||||
|
'serverCount' => $organisation->servers()->count(),
|
||||||
|
],
|
||||||
'servers' => inertia()->optional(function () use ($application) {
|
'servers' => inertia()->optional(function () use ($application) {
|
||||||
return $application
|
return $application
|
||||||
->organisation
|
->organisation
|
||||||
@@ -75,6 +90,51 @@ class ApplicationController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
return inertia('applications/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'repositoryTypes' => RepositoryType::toArray(),
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateApplicationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$application->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
|
||||||
|
'repository_type' => $request->enum('repository_type', RepositoryType::class),
|
||||||
|
'repository_url' => $request->string('repository_url')->toString(),
|
||||||
|
'default_branch' => $request->string('default_branch')->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Application updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Application deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
public function verifyRepository(Request $request): RedirectResponse
|
public function verifyRepository(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -86,4 +146,23 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', 'Repository access verified.');
|
return back()->with('success', 'Repository access verified.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rotateDeployKey(Request $request, GenerateDeployKey $generateDeployKey): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$generateDeployKey->execute($application);
|
||||||
|
|
||||||
|
return back()->with('success', 'Deploy key rotated. Install the new public key before verifying access.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceProviderIdFor(Organisation $organisation, ?int $sourceProviderId): ?int
|
||||||
|
{
|
||||||
|
if ($sourceProviderId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organisation->sourceProviders()->findOrFail($sourceProviderId)->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/Http/Controllers/BuildArtifactController.php
Normal file
44
app/Http/Controllers/BuildArtifactController.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class BuildArtifactController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('build-artifacts/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'artifacts' => $environment->buildArtifacts()
|
||||||
|
->with(['builtByOperation', 'builtByService'])
|
||||||
|
->latest()
|
||||||
|
->paginate(30),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
/** @var BuildArtifact $artifact */
|
||||||
|
$artifact = $environment->buildArtifacts()
|
||||||
|
->with(['builtByOperation.steps', 'builtByService'])
|
||||||
|
->findOrFail($request->route('artifact'));
|
||||||
|
|
||||||
|
return inertia('build-artifacts/Show', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'artifact' => $artifact,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Actions\Environments\AttachManagedService;
|
|||||||
use App\Enums\EnvironmentAttachmentRole;
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
|
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentAttachmentRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -27,6 +28,12 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name', 'type', 'category']),
|
->get(['id', 'name', 'type', 'category']),
|
||||||
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
||||||
|
'compatibility' => [
|
||||||
|
EnvironmentAttachmentRole::DATABASE->value => [ServiceType::POSTGRES->value],
|
||||||
|
EnvironmentAttachmentRole::CACHE->value => [ServiceType::VALKEY->value],
|
||||||
|
EnvironmentAttachmentRole::QUEUE->value => [ServiceType::VALKEY->value],
|
||||||
|
EnvironmentAttachmentRole::GATEWAY->value => [ServiceType::CADDY->value],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +44,7 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
$environment = $application->environments()->findOrFail($request->route('environment'));
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
$service = $organisation->services()->findOrFail($request->integer('service_id'));
|
$service = $organisation->services()->findOrFail($request->integer('service_id'));
|
||||||
|
|
||||||
app(AttachManagedService::class)->execute(
|
$attachment = app(AttachManagedService::class)->execute(
|
||||||
environment: $environment,
|
environment: $environment,
|
||||||
service: $service,
|
service: $service,
|
||||||
role: $request->enum('role', EnvironmentAttachmentRole::class),
|
role: $request->enum('role', EnvironmentAttachmentRole::class),
|
||||||
@@ -46,6 +53,17 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
isPrimary: $request->boolean('is_primary', true),
|
isPrimary: $request->boolean('is_primary', true),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY && $attachment->serviceSlice) {
|
||||||
|
$attachment->serviceSlice->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
|
||||||
|
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('environments.show', [
|
->route('environments.show', [
|
||||||
'organisation' => $organisation->id,
|
'organisation' => $organisation->id,
|
||||||
@@ -54,4 +72,73 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
])
|
])
|
||||||
->with('success', 'Managed service attached.');
|
->with('success', 'Managed service attached.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()
|
||||||
|
->with(['service', 'serviceSlice'])
|
||||||
|
->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
return inertia('environment-attachments/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentAttachmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
$attachment->update([
|
||||||
|
'role' => $request->enum('role', EnvironmentAttachmentRole::class),
|
||||||
|
'env_prefix' => $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
|
||||||
|
'is_primary' => $request->boolean('is_primary'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($attachment->serviceSlice && $request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY) {
|
||||||
|
$attachment->serviceSlice->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
|
||||||
|
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
'certificate_status' => $request->filled('certificate_status') ? $request->string('certificate_status')->toString() : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Attachment updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
$attachment->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Attachment detached.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,54 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Applications\CreateLaravelEnvironment;
|
||||||
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\SchedulerMode;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Http\Requests\StoreEnvironmentRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentRequest;
|
||||||
|
use App\Models\Environment;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Support\CaddyRouteRenderer;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class EnvironmentController extends Controller
|
class EnvironmentController extends Controller
|
||||||
{
|
{
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
return inertia('environments/Create', [
|
||||||
|
'application' => $application,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreEnvironmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$environment = app(CreateLaravelEnvironment::class)->execute(
|
||||||
|
application: $application,
|
||||||
|
name: $request->string('name')->toString(),
|
||||||
|
branch: $request->string('branch')->toString(),
|
||||||
|
phpVersion: $request->string('php_version')->toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment created.');
|
||||||
|
}
|
||||||
|
|
||||||
public function show(Request $request): Response
|
public function show(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -15,18 +57,151 @@ class EnvironmentController extends Controller
|
|||||||
$environment = $application->environments()
|
$environment = $application->environments()
|
||||||
->with([
|
->with([
|
||||||
'services.replicas',
|
'services.replicas',
|
||||||
|
'services.endpoints',
|
||||||
'services.slices',
|
'services.slices',
|
||||||
'services.operations.steps',
|
'services.operations.steps',
|
||||||
'attachments.service',
|
'attachments.service',
|
||||||
'attachments.serviceSlice',
|
'attachments.serviceSlice',
|
||||||
'variables',
|
'variables',
|
||||||
|
'buildArtifacts.builtByService',
|
||||||
'operations.steps',
|
'operations.steps',
|
||||||
|
'operations.children.target',
|
||||||
])
|
])
|
||||||
->findOrFail($request->route('environment'));
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$serverCount = $this->serverIdsFor($environment)->count();
|
||||||
|
|
||||||
return inertia('environments/Show', [
|
return inertia('environments/Show', [
|
||||||
'application' => $application,
|
'application' => $application,
|
||||||
'environment' => $environment,
|
'environment' => $environment,
|
||||||
|
'deploymentRequirements' => [
|
||||||
|
'registryRequired' => $organisation->registries()->doesntExist() && $serverCount > 1,
|
||||||
|
'registryCount' => $organisation->registries()->count(),
|
||||||
|
'serverCount' => $serverCount,
|
||||||
|
],
|
||||||
|
'gatewayRoutePreviews' => $this->gatewayRoutePreviews($environment),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with('services')
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('environments/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'schedulerModes' => array_values(SchedulerMode::toArray()),
|
||||||
|
'buildStrategies' => array_values(BuildStrategy::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with('services')
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$schedulerTargetServiceId = $request->integer('scheduler_target_service_id') ?: null;
|
||||||
|
|
||||||
|
if ($schedulerTargetServiceId !== null) {
|
||||||
|
abort_unless($environment->services()->whereKey($schedulerTargetServiceId)->exists(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'branch' => $request->string('branch')->toString(),
|
||||||
|
'status' => $request->string('status')->toString(),
|
||||||
|
'scheduler_enabled' => $request->boolean('scheduler_enabled'),
|
||||||
|
'scheduler_target_service_id' => $schedulerTargetServiceId,
|
||||||
|
'scheduler_mode' => $request->enum('scheduler_mode', SchedulerMode::class),
|
||||||
|
'build_config' => [
|
||||||
|
...($environment->build_config ?? []),
|
||||||
|
'build_strategy' => $request->enum('build_strategy', BuildStrategy::class)?->value,
|
||||||
|
'php_version' => $request->string('php_version')->toString(),
|
||||||
|
'document_root' => $request->string('document_root')->toString(),
|
||||||
|
'health_path' => $request->string('health_path')->toString(),
|
||||||
|
'js_package_manager' => $request->string('js_package_manager')->toString(),
|
||||||
|
'js_build_command' => $request->filled('js_build_command')
|
||||||
|
? $request->string('js_build_command')->toString()
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$environment->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, int>
|
||||||
|
*/
|
||||||
|
private function serverIdsFor(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->flatMap(fn ($service) => [
|
||||||
|
$service->server_id,
|
||||||
|
...$service->replicas->pluck('server_id')->all(),
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{attachment_id: int, caddyfile: string}>
|
||||||
|
*/
|
||||||
|
private function gatewayRoutePreviews(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
$upstreams = $this->previewGatewayUpstreams($environment);
|
||||||
|
$renderer = app(CaddyRouteRenderer::class);
|
||||||
|
|
||||||
|
return $environment->attachments
|
||||||
|
->filter(fn ($attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
|
||||||
|
->map(fn ($attachment): array => [
|
||||||
|
'attachment_id' => $attachment->id,
|
||||||
|
'caddyfile' => $renderer->render($attachment, $upstreams),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function previewGatewayUpstreams(Environment $environment): array
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->filter(fn ($service): bool => $service->type === ServiceType::LARAVEL && in_array('web', $service->process_roles ?? [], true))
|
||||||
|
->flatMap(fn ($service) => $service->replicas->map(
|
||||||
|
fn ($replica): string => ($replica->internal_host ?: $replica->container_name).':'.($replica->internal_port ?: 80)
|
||||||
|
))
|
||||||
|
->values()
|
||||||
|
->whenEmpty(fn ($upstreams) => $upstreams->push('web:80'))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreEnvironmentDeploymentRequest;
|
||||||
use App\Jobs\Environments\DeployEnvironment;
|
use App\Jobs\Environments\DeployEnvironment;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class EnvironmentDeploymentController extends Controller
|
class EnvironmentDeploymentController extends Controller
|
||||||
{
|
{
|
||||||
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(
|
abort_unless(
|
||||||
(int) $application->organisation_id === (int) $organisation->id
|
(int) $application->organisation_id === (int) $organisation->id
|
||||||
@@ -18,7 +20,16 @@ class EnvironmentDeploymentController extends Controller
|
|||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch(new DeployEnvironment($environment));
|
$environment->loadMissing('services.replicas');
|
||||||
|
|
||||||
|
if ($organisation->registries()->doesntExist() && $this->serverIdsFor($environment)->count() > 1) {
|
||||||
|
return back()->with('error', 'Configure a registry before deploying this environment to multiple servers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(new DeployEnvironment(
|
||||||
|
environment: $environment,
|
||||||
|
targetCommit: $request->validated('target_commit') ?: null,
|
||||||
|
));
|
||||||
|
|
||||||
return redirect()->route('environments.show', [
|
return redirect()->route('environments.show', [
|
||||||
'organisation' => $organisation->id,
|
'organisation' => $organisation->id,
|
||||||
@@ -26,4 +37,19 @@ class EnvironmentDeploymentController extends Controller
|
|||||||
'environment' => $environment->id,
|
'environment' => $environment->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, int>
|
||||||
|
*/
|
||||||
|
private function serverIdsFor(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->flatMap(fn ($service) => [
|
||||||
|
$service->server_id,
|
||||||
|
...$service->replicas->pluck('server_id')->all(),
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/Http/Controllers/EnvironmentIndexController.php
Normal file
24
app/Http/Controllers/EnvironmentIndexController.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EnvironmentIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
$applications = $organisation->applications()
|
||||||
|
->with([
|
||||||
|
'environments' => fn ($query) => $query
|
||||||
|
->withCount(['services', 'attachments', 'variables', 'buildArtifacts'])
|
||||||
|
->latest(),
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return inertia('environments/Index', [
|
||||||
|
'applications' => $applications,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\EnvironmentVariableSource;
|
use App\Enums\EnvironmentVariableSource;
|
||||||
|
use App\Http\Requests\ImportEnvironmentVariablesRequest;
|
||||||
use App\Http\Requests\StoreEnvironmentVariableRequest;
|
use App\Http\Requests\StoreEnvironmentVariableRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentVariableRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,6 +13,22 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class EnvironmentVariableController extends Controller
|
class EnvironmentVariableController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('environment-variables/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'variables' => $environment->variables()
|
||||||
|
->with('serviceSlice')
|
||||||
|
->orderBy('key')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -35,11 +53,136 @@ class EnvironmentVariableController extends Controller
|
|||||||
'value' => $request->string('value')->toString(),
|
'value' => $request->string('value')->toString(),
|
||||||
'source' => EnvironmentVariableSource::USER,
|
'source' => EnvironmentVariableSource::USER,
|
||||||
'service_slice_id' => null,
|
'service_slice_id' => null,
|
||||||
'overridable' => true,
|
'overridable' => $request->boolean('overridable', true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
->with('success', 'Environment variable saved.');
|
->with('success', 'Environment variable saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function import(ImportEnvironmentVariablesRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($this->parseDotEnv($request->string('contents')->toString()) as $key => $value) {
|
||||||
|
$environment->variables()->updateOrCreate([
|
||||||
|
'key' => $key,
|
||||||
|
], [
|
||||||
|
'value' => $value,
|
||||||
|
'source' => EnvironmentVariableSource::USER,
|
||||||
|
'service_slice_id' => null,
|
||||||
|
'overridable' => $request->boolean('overridable', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', "{$count} environment variables imported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->with('serviceSlice')->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
return inertia('environment-variables/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'variable' => $variable,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentVariableRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
$variable->update([
|
||||||
|
'key' => $request->string('key')->toString(),
|
||||||
|
'value' => $request->string('value')->toString(),
|
||||||
|
'overridable' => $request->boolean('overridable'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment variable updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
$variable->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment variable deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function parseDotEnv(string $contents): array
|
||||||
|
{
|
||||||
|
return collect(preg_split('/\R/', $contents) ?: [])
|
||||||
|
->map(fn (string $line): string => trim($line))
|
||||||
|
->reject(fn (string $line): bool => $line === '' || str_starts_with($line, '#'))
|
||||||
|
->mapWithKeys(function (string $line): array {
|
||||||
|
if (str_starts_with($line, 'export ')) {
|
||||||
|
$line = trim(substr($line, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||||||
|
$key = trim($key);
|
||||||
|
|
||||||
|
if (! preg_match('/^[A-Z][A-Z0-9_]*$/', $key)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$key => $this->unquoteDotEnvValue(trim($value))];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unquoteDotEnvValue(string $value): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
||||||
|
return stripcslashes(substr($value, 1, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
|
||||||
|
return substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
app/Http/Controllers/GatewayRouteController.php
Normal file
162
app/Http/Controllers/GatewayRouteController.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Environments\AttachManagedService;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Http\Requests\StoreGatewayRouteRequest;
|
||||||
|
use App\Http\Requests\UpdateGatewayRouteRequest;
|
||||||
|
use App\Models\EnvironmentAttachment;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class GatewayRouteController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with(['attachments.service', 'attachments.serviceSlice'])
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'routes' => $environment->attachments
|
||||||
|
->filter(fn (EnvironmentAttachment $attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
|
||||||
|
->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Create', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'services' => $organisation->services()
|
||||||
|
->where('type', ServiceType::CADDY->value)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'type', 'category']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreGatewayRouteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$service = $organisation->services()
|
||||||
|
->where('type', ServiceType::CADDY->value)
|
||||||
|
->findOrFail($request->integer('service_id'));
|
||||||
|
|
||||||
|
$attachment = app(AttachManagedService::class)->execute(
|
||||||
|
environment: $environment,
|
||||||
|
service: $service,
|
||||||
|
role: EnvironmentAttachmentRole::GATEWAY,
|
||||||
|
name: $request->string('name')->toString(),
|
||||||
|
isPrimary: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$attachment->serviceSlice?->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
...$this->routeConfig($request),
|
||||||
|
'certificate_status' => $request->boolean('tls_enabled', true) ? 'pending' : 'disabled',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->with(['service', 'serviceSlice'])
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'routeAttachment' => $route,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateGatewayRouteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->with('serviceSlice')
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
$route->serviceSlice?->update([
|
||||||
|
'config' => [
|
||||||
|
...($route->serviceSlice->config ?? []),
|
||||||
|
...$this->routeConfig($request),
|
||||||
|
'certificate_status' => $request->filled('certificate_status')
|
||||||
|
? $request->string('certificate_status')->toString()
|
||||||
|
: ($request->boolean('tls_enabled', true) ? 'pending' : 'disabled'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
$route->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{domain: string, path_prefix: string, tls_enabled: bool}
|
||||||
|
*/
|
||||||
|
private function routeConfig(StoreGatewayRouteRequest|UpdateGatewayRouteRequest $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => $request->string('domain')->toString(),
|
||||||
|
'path_prefix' => $request->string('path_prefix')->toString(),
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ class OnboardingController extends Controller
|
|||||||
{
|
{
|
||||||
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
||||||
|
|
||||||
|
$applicationNeedingDeployKey = $organisation->applications()
|
||||||
|
->whereNull('deploy_key_installed_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
$steps = [
|
$steps = [
|
||||||
[
|
[
|
||||||
'key' => 'organisation',
|
'key' => 'organisation',
|
||||||
@@ -48,6 +52,17 @@ class OnboardingController extends Controller
|
|||||||
'complete' => $organisation->applications_count > 0,
|
'complete' => $organisation->applications_count > 0,
|
||||||
'href' => route('applications.create', ['organisation' => $organisation->id]),
|
'href' => route('applications.create', ['organisation' => $organisation->id]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'deploy-key',
|
||||||
|
'label' => 'Deploy key',
|
||||||
|
'complete' => $organisation->applications_count === 0 || $applicationNeedingDeployKey === null,
|
||||||
|
'href' => $applicationNeedingDeployKey
|
||||||
|
? route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $applicationNeedingDeployKey->id,
|
||||||
|
])
|
||||||
|
: route('applications.index', ['organisation' => $organisation->id]),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
|
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
|
||||||
|
|||||||
171
app/Http/Controllers/OperationController.php
Normal file
171
app/Http/Controllers/OperationController.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceReplica;
|
||||||
|
use App\Models\ServiceSlice;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class OperationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
$applicationIds = $organisation->applications()->pluck('id');
|
||||||
|
$environmentIds = Environment::query()
|
||||||
|
->whereIn('application_id', $applicationIds)
|
||||||
|
->pluck('id');
|
||||||
|
$serverIds = $organisation->servers()->pluck('id');
|
||||||
|
$serviceIds = $organisation->services()->pluck('id');
|
||||||
|
$replicaIds = ServiceReplica::query()
|
||||||
|
->whereIn('service_id', $serviceIds)
|
||||||
|
->pluck('id');
|
||||||
|
$sliceIds = ServiceSlice::query()
|
||||||
|
->whereIn('service_id', $serviceIds)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$operations = Operation::query()
|
||||||
|
->with(['target', 'parent', 'children.target'])
|
||||||
|
->withCount('steps', 'children')
|
||||||
|
->where(function (Builder $query) use ($applicationIds, $environmentIds, $serverIds, $serviceIds, $replicaIds, $sliceIds): void {
|
||||||
|
$query
|
||||||
|
->where(function (Builder $query) use ($applicationIds): void {
|
||||||
|
$query->where('target_type', (new Application)->getMorphClass())
|
||||||
|
->whereIn('target_id', $applicationIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($environmentIds): void {
|
||||||
|
$query->where('target_type', (new Environment)->getMorphClass())
|
||||||
|
->whereIn('target_id', $environmentIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($serverIds): void {
|
||||||
|
$query->where('target_type', (new Server)->getMorphClass())
|
||||||
|
->whereIn('target_id', $serverIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($serviceIds): void {
|
||||||
|
$query->where('target_type', (new Service)->getMorphClass())
|
||||||
|
->whereIn('target_id', $serviceIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($replicaIds): void {
|
||||||
|
$query->where('target_type', (new ServiceReplica)->getMorphClass())
|
||||||
|
->whereIn('target_id', $replicaIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($sliceIds): void {
|
||||||
|
$query->where('target_type', (new ServiceSlice)->getMorphClass())
|
||||||
|
->whereIn('target_id', $sliceIds);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($request->filled('kind'), fn (Builder $query) => $query->where('kind', $request->string('kind')->toString()))
|
||||||
|
->when($request->filled('status'), fn (Builder $query) => $query->where('status', $request->string('status')->toString()))
|
||||||
|
->latest()
|
||||||
|
->paginate(30)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return inertia('operations/Index', [
|
||||||
|
'operations' => $operations,
|
||||||
|
'filters' => $request->only(['kind', 'status']),
|
||||||
|
'operationKinds' => OperationKind::toArray(),
|
||||||
|
'operationStatuses' => OperationStatus::toArray(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Organisation $organisation, Operation $operation): Response
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->load([
|
||||||
|
'target',
|
||||||
|
'parent.target',
|
||||||
|
'children.target',
|
||||||
|
'children.steps',
|
||||||
|
'steps',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return inertia('operations/Show', [
|
||||||
|
'operation' => $operation,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retry(Organisation $organisation, Operation $operation): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->update([
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'started_at' => null,
|
||||||
|
'finished_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('operations.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'operation' => $operation->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Operation queued to run again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Organisation $organisation, Operation $operation): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->update([
|
||||||
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
'finished_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('operations.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'operation' => $operation->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Operation cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadLogs(Organisation $organisation, Operation $operation): StreamedResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->load('steps');
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($operation): void {
|
||||||
|
foreach ($operation->steps as $step) {
|
||||||
|
echo "# {$step->name}\n\n";
|
||||||
|
|
||||||
|
if ($step->logs) {
|
||||||
|
echo "## Logs\n{$step->logs}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($step->error_logs) {
|
||||||
|
echo "## Error Logs\n{$step->error_logs}\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "operation-{$operation->hash}.log", [
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationBelongsToOrganisation(Operation $operation, Organisation $organisation): bool
|
||||||
|
{
|
||||||
|
$target = $operation->target;
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$target instanceof Application => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof Environment => $target->application()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
$target instanceof Server => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof Service => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof ServiceReplica => $target->service()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
$target instanceof ServiceSlice => $target->service()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Models\Operation;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Provider;
|
use App\Models\Provider;
|
||||||
|
use App\Models\Service;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
@@ -15,7 +18,23 @@ class OrganisationController extends Controller
|
|||||||
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
|
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
|
||||||
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
|
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
|
||||||
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
|
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
|
||||||
'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')),
|
'organisation' => Organisation::with('members')
|
||||||
|
->withCount('servers', 'applications', 'members', 'providers', 'sourceProviders', 'registries')
|
||||||
|
->findOrFail($request->route('organisation')),
|
||||||
|
'health' => [
|
||||||
|
'unhealthy_services' => Service::query()
|
||||||
|
->where('organisation_id', $request->route('organisation'))
|
||||||
|
->whereNot('status', ServiceStatus::RUNNING)
|
||||||
|
->count(),
|
||||||
|
'failed_operations' => Operation::query()
|
||||||
|
->whereHasMorph('target', [Service::class], fn ($query) => $query->where('organisation_id', $request->route('organisation')))
|
||||||
|
->where('status', 'failed')
|
||||||
|
->count(),
|
||||||
|
'locked_variables' => Organisation::findOrFail($request->route('organisation'))
|
||||||
|
->applications()
|
||||||
|
->whereHas('environments.variables', fn ($query) => $query->where('overridable', false))
|
||||||
|
->count(),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
app/Http/Controllers/OrganisationMemberController.php
Normal file
120
app/Http/Controllers/OrganisationMemberController.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use App\Http\Requests\StoreOrganisationMemberRequest;
|
||||||
|
use App\Http\Requests\UpdateOrganisationInvitationRequest;
|
||||||
|
use App\Http\Requests\UpdateOrganisationMemberRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\OrganisationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class OrganisationMemberController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
return inertia('organisation-members/Index', [
|
||||||
|
'organisation' => $organisation->load(['members', 'invitations.invitedBy']),
|
||||||
|
'roles' => array_values(OrganisationRole::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreOrganisationMemberRequest $request, Organisation $organisation): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = Str::lower($request->string('email')->toString());
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
abort_if(
|
||||||
|
$organisation->invitations()->where('email', $email)->whereNull('accepted_at')->exists(),
|
||||||
|
422,
|
||||||
|
'This email already has a pending invitation.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$organisation->invitations()->create([
|
||||||
|
'email' => $email,
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
'token' => Str::random(40),
|
||||||
|
'invited_by_user_id' => $request->user()?->id,
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$organisation->members()->syncWithoutDetaching([
|
||||||
|
$user->id => ['role' => $request->enum('role', OrganisationRole::class)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$organisation->invitations()
|
||||||
|
->where('email', $email)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateOrganisationMemberRequest $request, Organisation $organisation, User $member): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($organisation->members()->whereKey($member->id)->exists(), 404);
|
||||||
|
|
||||||
|
$organisation->members()->updateExistingPivot($member->id, [
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member role updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateInvitation(
|
||||||
|
UpdateOrganisationInvitationRequest $request,
|
||||||
|
Organisation $organisation,
|
||||||
|
OrganisationInvitation $invitation
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless($invitation->organisation_id === $organisation->id, 404);
|
||||||
|
|
||||||
|
$invitation->update([
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation role updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Organisation $organisation, User $member): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_if($organisation->owner_id === $member->id, 422, 'The organisation owner cannot be removed.');
|
||||||
|
|
||||||
|
$organisation->members()->detach($member->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyInvitation(
|
||||||
|
Request $request,
|
||||||
|
Organisation $organisation,
|
||||||
|
OrganisationInvitation $invitation
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless($invitation->organisation_id === $organisation->id, 404);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation cancelled.');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/ProviderController.php
Normal file
49
app/Http/Controllers/ProviderController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ProviderType;
|
||||||
|
use App\Http\Requests\StoreProviderRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProviderController extends Controller
|
||||||
|
{
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('providers/Create', [
|
||||||
|
'providerTypes' => array_values(ProviderType::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProviderRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
$organisation->providers()->create([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', ProviderType::class),
|
||||||
|
'token' => $request->string('token')->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server provider created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$provider = $organisation->providers()->findOrFail($request->route('provider'));
|
||||||
|
|
||||||
|
$provider->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server provider deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,27 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Enums\RegistryType;
|
use App\Enums\RegistryType;
|
||||||
use App\Http\Requests\StoreRegistryRequest;
|
use App\Http\Requests\StoreRegistryRequest;
|
||||||
|
use App\Http\Requests\UpdateRegistryRequest;
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Registry;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class RegistryController extends Controller
|
class RegistryController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('registries/Index', [
|
||||||
|
'registries' => $organisation->registries()
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -38,4 +52,75 @@ class RegistryController extends Controller
|
|||||||
->route('organisations.show', ['organisation' => $organisation->id])
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
->with('success', 'Registry created.');
|
->with('success', 'Registry created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var Registry $registry */
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
$registryUrl = rtrim((string) $registry->url, '/');
|
||||||
|
$artifacts = BuildArtifact::query()
|
||||||
|
->with(['environment.application', 'builtByService'])
|
||||||
|
->whereHas('environment.application', fn ($query) => $query->where('organisation_id', $organisation->id))
|
||||||
|
->when($registryUrl !== '', fn ($query) => $query->where('registry_ref', 'like', $registryUrl.'%'));
|
||||||
|
|
||||||
|
return inertia('registries/Show', [
|
||||||
|
'registry' => $registry,
|
||||||
|
'artifactCount' => (clone $artifacts)->count(),
|
||||||
|
'environmentCount' => (clone $artifacts)->distinct('environment_id')->count('environment_id'),
|
||||||
|
'artifacts' => $artifacts
|
||||||
|
->latest()
|
||||||
|
->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
return inertia('registries/Edit', [
|
||||||
|
'registry' => $registry,
|
||||||
|
'registryTypes' => array_values(RegistryType::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateRegistryRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var Registry $registry */
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$username = $request->string('username')->toString();
|
||||||
|
|
||||||
|
if ($request->filled('password')) {
|
||||||
|
$credentials['password'] = $request->string('password')->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials['username'] = $username;
|
||||||
|
|
||||||
|
$registry->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', RegistryType::class),
|
||||||
|
'url' => rtrim($request->string('url')->toString(), '/'),
|
||||||
|
'credentials' => $credentials,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Registry updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
$registry->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Registry deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,33 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\GenerateRandomSlug;
|
use App\Actions\GenerateRandomSlug;
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
use App\Enums\ServerStatus;
|
use App\Enums\ServerStatus;
|
||||||
use App\Jobs\Servers\WaitForServerToConnect;
|
use App\Jobs\Servers\WaitForServerToConnect;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Provider;
|
use App\Models\Provider;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class ServerController extends Controller
|
class ServerController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
return inertia('servers/Index', [
|
return inertia('servers/Index', [
|
||||||
'servers' => $organisation->servers()->paginate(30),
|
'servers' => $organisation->servers()->paginate(30),
|
||||||
|
'networks' => $organisation->networks()
|
||||||
|
->with(['servers' => fn ($query) => $query->select('id', 'network_id', 'name', 'private_ip', 'status')])
|
||||||
|
->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Request $request)
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
@@ -55,7 +62,7 @@ class ServerController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'provider' => ['required', 'exists:providers,id'],
|
'provider' => ['required', 'exists:providers,id'],
|
||||||
@@ -135,13 +142,63 @@ class ServerController extends Controller
|
|||||||
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
|
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Request $request)
|
public function show(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
$server = $organisation->servers()->findOrFail($request->route('server'));
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
return inertia('servers/Show', [
|
return inertia('servers/Show', [
|
||||||
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
|
'server' => $server->load(
|
||||||
|
'firewallRules',
|
||||||
|
'network',
|
||||||
|
'operations.steps',
|
||||||
|
'operations.children.target',
|
||||||
|
'services.slices',
|
||||||
|
'services.endpoints',
|
||||||
|
'serviceOperations.steps',
|
||||||
|
'serviceOperations.children.target',
|
||||||
|
'serviceOperations.target',
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
|
$server->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function heal(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
|
$operation = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVER_PROVISION,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'Check server shell' => 'true',
|
||||||
|
'Check Docker' => 'docker --version && docker compose version',
|
||||||
|
'Check Keystone directories' => 'test -d /home/keystone && test -d /home/keystone/services',
|
||||||
|
] as $order => $script) {
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => $order,
|
||||||
|
'order' => $operation->steps()->count() + 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $script,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id])
|
||||||
|
->with('success', 'Server heal operation queued.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Http/Controllers/ServerFirewallRuleController.php
Normal file
41
app/Http/Controllers/ServerFirewallRuleController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\FirewallRules\UninstallFirewallRule;
|
||||||
|
use App\Http\Requests\StoreServerFirewallRuleRequest;
|
||||||
|
use App\Models\FirewallRule;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ServerFirewallRuleController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreServerFirewallRuleRequest $request, Organisation $organisation, Server $server): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless((int) $server->organisation_id === (int) $organisation->id, 404);
|
||||||
|
|
||||||
|
$server->firewallRules()->create($request->validated());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [$organisation, $server])
|
||||||
|
->with('success', 'Firewall rule queued for installation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Organisation $organisation, Server $server, FirewallRule $firewallRule, UninstallFirewallRule $uninstallFirewallRule): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
(int) $server->organisation_id === (int) $organisation->id
|
||||||
|
&& (int) $firewallRule->server_id === (int) $server->id,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
$uninstallFirewallRule->execute($firewallRule);
|
||||||
|
$firewallRule->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [$organisation, $server])
|
||||||
|
->with('success', 'Firewall rule removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Services\CreateService;
|
use App\Actions\Services\CreateService;
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
use App\Enums\ServiceCategory;
|
use App\Enums\ServiceCategory;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreServiceRequest;
|
use App\Http\Requests\StoreServiceRequest;
|
||||||
use App\Http\Requests\UpdateServiceRequest;
|
use App\Http\Requests\UpdateServiceRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -49,7 +51,7 @@ class ServiceController extends Controller
|
|||||||
{
|
{
|
||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
$service = $server->services()
|
$service = $server->services()
|
||||||
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
|
->with(['replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
|
||||||
->findOrFail($request->route('service'));
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
return inertia('services/Show', [
|
return inertia('services/Show', [
|
||||||
@@ -58,6 +60,23 @@ class ServiceController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function showForEnvironment(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$service = $environment->services()
|
||||||
|
->with(['server', 'replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
|
||||||
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('services/Show', [
|
||||||
|
'server' => $service->server,
|
||||||
|
'service' => $service,
|
||||||
|
'environment' => $environment,
|
||||||
|
'application' => $application,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function edit(Request $request): Response
|
public function edit(Request $request): Response
|
||||||
{
|
{
|
||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
@@ -66,6 +85,7 @@ class ServiceController extends Controller
|
|||||||
return inertia('services/Edit', [
|
return inertia('services/Edit', [
|
||||||
'server' => $server,
|
'server' => $server,
|
||||||
'service' => $service,
|
'service' => $service,
|
||||||
|
'deployPolicies' => array_values(DeployPolicy::toArray()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +94,31 @@ class ServiceController extends Controller
|
|||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
$service = $server->services()->findOrFail($request->route('service'));
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
$service->update($request->validated());
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$service->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'desired_replicas' => $validated['desired_replicas'],
|
||||||
|
'default_cpu_limit' => $validated['default_cpu_limit'] ?? null,
|
||||||
|
'default_memory_limit_mb' => $validated['default_memory_limit_mb'] ?? null,
|
||||||
|
'deploy_policy' => $request->enum('deploy_policy', DeployPolicy::class) ?? $service->deploy_policy,
|
||||||
|
'version_track' => $validated['version_track'] ?? $service->version_track,
|
||||||
|
'available_image_digest' => $validated['available_image_digest'] ?? null,
|
||||||
|
'process_roles' => collect(explode(',', $validated['process_roles'] ?? ''))
|
||||||
|
->map(fn (string $role): string => trim($role))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'config' => [
|
||||||
|
...($service->config ?? []),
|
||||||
|
'migration_mode' => $validated['migration_mode'] ?? null,
|
||||||
|
'migration_timing' => $validated['migration_timing'] ?? null,
|
||||||
|
'migration_command' => $validated['migration_command'] ?? null,
|
||||||
|
'health_path' => $validated['health_path'] ?? null,
|
||||||
|
'backup_enabled' => $request->boolean('backup_enabled'),
|
||||||
|
'backup_command' => $validated['backup_command'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('services.show', [
|
->route('services.show', [
|
||||||
@@ -84,4 +128,19 @@ class ServiceController extends Controller
|
|||||||
])
|
])
|
||||||
->with('success', 'Service updated.');
|
->with('success', 'Service updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$server = Server::findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
$service->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [
|
||||||
|
'organisation' => $server->organisation_id,
|
||||||
|
'server' => $server->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Service deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
app/Http/Controllers/ServiceReplicaController.php
Normal file
102
app/Http/Controllers/ServiceReplicaController.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\ServiceReplica;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ServiceReplicaController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()
|
||||||
|
->with(['server', 'operation.steps', 'operations.steps', 'operations.children.target'])
|
||||||
|
->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
return inertia('service-replicas/Show', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'replica' => $replica,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restart(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Restart replica', "docker restart {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica restart queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Start replica', "docker start {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica start queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stop(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Stop replica', "docker stop {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica stop queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueLifecycleOperation(ServiceReplica $replica, string $name, string $script): void
|
||||||
|
{
|
||||||
|
$operation = $replica->operations()->create([
|
||||||
|
'kind' => OperationKind::REPLICA_DEPLOY,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => $name,
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $script,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/ServiceSliceController.php
Normal file
104
app/Http/Controllers/ServiceSliceController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreServiceSliceRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\ServiceSlice;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ServiceSliceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()
|
||||||
|
->with('environment.application')
|
||||||
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Index', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'slices' => $service->slices()
|
||||||
|
->with(['environment.application', 'attachments', 'operations.steps', 'operations.children.target'])
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
|
||||||
|
$slice = $service->slices()
|
||||||
|
->with(['environment.application', 'attachments.environment.application', 'operations.steps', 'operations.children.target'])
|
||||||
|
->findOrFail($request->route('slice'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Show', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'slice' => $slice,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Create', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'environments' => $organisation->applications()
|
||||||
|
->with('environments')
|
||||||
|
->get()
|
||||||
|
->pluck('environments')
|
||||||
|
->flatten()
|
||||||
|
->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreServiceSliceRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$environmentId = $request->integer('environment_id') ?: null;
|
||||||
|
|
||||||
|
if ($environmentId !== null) {
|
||||||
|
$belongsToOrganisation = $organisation->applications()
|
||||||
|
->whereHas('environments', fn ($query) => $query->whereKey($environmentId))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($belongsToOrganisation, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $request->filled('config')
|
||||||
|
? json_decode($request->string('config')->toString(), true, flags: JSON_THROW_ON_ERROR)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/** @var ServiceSlice $slice */
|
||||||
|
$slice = $service->slices()->create([
|
||||||
|
'environment_id' => $environmentId,
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->string('type')->toString(),
|
||||||
|
'status' => $request->string('status')->toString(),
|
||||||
|
'config' => $config,
|
||||||
|
'credentials' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-slices.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'slice' => $slice->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Slice created.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
|
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
|
||||||
|
use App\Actions\Services\ResolveServiceImageDigest;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreServiceUpdateRequest;
|
use App\Http\Requests\StoreServiceUpdateRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
@@ -33,6 +34,7 @@ class ServiceUpdateController extends Controller
|
|||||||
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
|
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
|
||||||
): RedirectResponse {
|
): RedirectResponse {
|
||||||
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
||||||
|
abort_unless($request->string('confirmation')->toString() === $service->name, 422);
|
||||||
|
|
||||||
$createStatefulServiceUpdateOperation->execute(
|
$createStatefulServiceUpdateOperation->execute(
|
||||||
service: $service,
|
service: $service,
|
||||||
@@ -45,4 +47,26 @@ class ServiceUpdateController extends Controller
|
|||||||
'server' => $server->id,
|
'server' => $server->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolve(
|
||||||
|
Organisation $organisation,
|
||||||
|
Server $server,
|
||||||
|
Service $service,
|
||||||
|
ResolveServiceImageDigest $resolveServiceImageDigest,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
||||||
|
abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404);
|
||||||
|
|
||||||
|
$service->update([
|
||||||
|
'available_image_digest' => $resolveServiceImageDigest->execute($service),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-updates.create', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Latest image digest resolved.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,26 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Enums\SourceProviderType;
|
use App\Enums\SourceProviderType;
|
||||||
use App\Http\Requests\StoreSourceProviderRequest;
|
use App\Http\Requests\StoreSourceProviderRequest;
|
||||||
|
use App\Http\Requests\UpdateSourceProviderRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\SourceProvider;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class SourceProviderController extends Controller
|
class SourceProviderController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('source-providers/Index', [
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -35,4 +48,44 @@ class SourceProviderController extends Controller
|
|||||||
->route('organisations.show', ['organisation' => $organisation->id])
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
->with('success', 'Source provider created.');
|
->with('success', 'Source provider created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
return inertia('source-providers/Edit', [
|
||||||
|
'sourceProvider' => $sourceProvider,
|
||||||
|
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateSourceProviderRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var SourceProvider $sourceProvider */
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
$sourceProvider->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', SourceProviderType::class),
|
||||||
|
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Source provider updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
$sourceProvider->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Source provider deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class HandleInertiaRequests extends Middleware
|
|||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'organisation' => $request->route('organisation')
|
'organisation' => $request->route('organisation')
|
||||||
? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation')))
|
? Organisation::with('applications.environments')
|
||||||
|
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications'])
|
||||||
|
->findOrFail($this->routeKey($request->route('organisation')))
|
||||||
: null,
|
: null,
|
||||||
'application' => $request->route('application')
|
'application' => $request->route('application')
|
||||||
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
||||||
|
|||||||
29
app/Http/Requests/ImportEnvironmentVariablesRequest.php
Normal file
29
app/Http/Requests/ImportEnvironmentVariablesRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ImportEnvironmentVariablesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'contents' => ['required', 'string', 'max:20000'],
|
||||||
|
'overridable' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreApplicationRequest extends FormRequest
|
class StoreApplicationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -23,6 +25,8 @@ class StoreApplicationRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
|
||||||
|
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
|
||||||
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
||||||
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
'environment_name' => ['required', 'string', 'max:255'],
|
'environment_name' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class StoreEnvironmentAttachmentRequest extends FormRequest
|
|||||||
'name' => ['nullable', 'string', 'max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
'is_primary' => ['boolean'],
|
'is_primary' => ['boolean'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['nullable', 'string', 'max:255'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Http/Requests/StoreEnvironmentDeploymentRequest.php
Normal file
28
app/Http/Requests/StoreEnvironmentDeploymentRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEnvironmentDeploymentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target_commit' => ['nullable', 'string', 'size:40', 'regex:/^[a-fA-F0-9]{40}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/StoreEnvironmentRequest.php
Normal file
30
app/Http/Requests/StoreEnvironmentRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEnvironmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
'php_version' => ['required', 'string', 'max:20'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class StoreEnvironmentVariableRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
'value' => ['nullable', 'string'],
|
'value' => ['nullable', 'string'],
|
||||||
|
'overridable' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Requests/StoreGatewayRouteRequest.php
Normal file
32
app/Http/Requests/StoreGatewayRouteRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreGatewayRouteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'service_id' => ['required', 'integer', 'exists:services,id'],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'domain' => ['required', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/StoreOrganisationMemberRequest.php
Normal file
31
app/Http/Requests/StoreOrganisationMemberRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreOrganisationMemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/StoreProviderRequest.php
Normal file
32
app/Http/Requests/StoreProviderRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\ProviderType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreProviderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(ProviderType::class)],
|
||||||
|
'token' => ['required', 'string', 'max:2000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/StoreServerFirewallRuleRequest.php
Normal file
32
app/Http/Requests/StoreServerFirewallRuleRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\FirewallRuleType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreServerFirewallRuleRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', Rule::enum(FirewallRuleType::class)],
|
||||||
|
'ports' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9:\/,-]+$/'],
|
||||||
|
'from' => ['nullable', 'string', 'max:255', 'regex:/^[A-Fa-f0-9:.\/-]+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/StoreServiceSliceRequest.php
Normal file
32
app/Http/Requests/StoreServiceSliceRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreServiceSliceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', 'string', 'max:255'],
|
||||||
|
'environment_id' => ['nullable', 'integer'],
|
||||||
|
'status' => ['required', 'string', 'max:255'],
|
||||||
|
'config' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
|
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
|
||||||
'backup_requested' => ['sometimes', 'boolean'],
|
'backup_requested' => ['sometimes', 'boolean'],
|
||||||
|
'confirmation' => ['required', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/Http/Requests/UpdateApplicationRequest.php
Normal file
34
app/Http/Requests/UpdateApplicationRequest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
|
||||||
|
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
|
||||||
|
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
||||||
|
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Requests/UpdateEnvironmentAttachmentRequest.php
Normal file
36
app/Http/Requests/UpdateEnvironmentAttachmentRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateEnvironmentAttachmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)],
|
||||||
|
'env_prefix' => ['nullable', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
|
'is_primary' => ['boolean'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['nullable', 'string', 'max:255'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
'certificate_status' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Requests/UpdateEnvironmentRequest.php
Normal file
42
app/Http/Requests/UpdateEnvironmentRequest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\SchedulerMode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateEnvironmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
'status' => ['required', 'string', 'max:255'],
|
||||||
|
'scheduler_enabled' => ['boolean'],
|
||||||
|
'scheduler_target_service_id' => ['nullable', 'integer'],
|
||||||
|
'scheduler_mode' => ['required', Rule::enum(SchedulerMode::class)],
|
||||||
|
'build_strategy' => ['required', Rule::enum(BuildStrategy::class)],
|
||||||
|
'php_version' => ['nullable', 'string', 'max:20'],
|
||||||
|
'document_root' => ['nullable', 'string', 'max:255'],
|
||||||
|
'health_path' => ['nullable', 'string', 'max:255'],
|
||||||
|
'js_package_manager' => ['nullable', 'string', 'max:50'],
|
||||||
|
'js_build_command' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateEnvironmentVariableRequest.php
Normal file
30
app/Http/Requests/UpdateEnvironmentVariableRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateEnvironmentVariableRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
|
'value' => ['nullable', 'string'],
|
||||||
|
'overridable' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/UpdateGatewayRouteRequest.php
Normal file
31
app/Http/Requests/UpdateGatewayRouteRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateGatewayRouteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => ['required', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
'certificate_status' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateOrganisationInvitationRequest.php
Normal file
30
app/Http/Requests/UpdateOrganisationInvitationRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateOrganisationInvitationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateOrganisationMemberRequest.php
Normal file
30
app/Http/Requests/UpdateOrganisationMemberRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateOrganisationMemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Requests/UpdateRegistryRequest.php
Normal file
34
app/Http/Requests/UpdateRegistryRequest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateRegistryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(RegistryType::class)],
|
||||||
|
'url' => ['required', 'string', 'max:255'],
|
||||||
|
'username' => ['nullable', 'string', 'max:255'],
|
||||||
|
'password' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateServiceRequest extends FormRequest
|
class UpdateServiceRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,16 @@ class UpdateServiceRequest extends FormRequest
|
|||||||
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
|
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
|
||||||
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
|
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
|
||||||
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
|
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
|
||||||
|
'deploy_policy' => ['nullable', Rule::enum(DeployPolicy::class)],
|
||||||
|
'version_track' => ['nullable', 'string', 'max:255'],
|
||||||
|
'available_image_digest' => ['nullable', 'string', 'max:255'],
|
||||||
|
'process_roles' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_mode' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_timing' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_command' => ['nullable', 'string', 'max:255'],
|
||||||
|
'health_path' => ['nullable', 'string', 'max:255'],
|
||||||
|
'backup_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'backup_command' => ['nullable', 'string', 'max:1000'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Requests/UpdateSourceProviderRequest.php
Normal file
32
app/Http/Requests/UpdateSourceProviderRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\SourceProviderType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSourceProviderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(SourceProviderType::class)],
|
||||||
|
'url' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use App\Models\Operation;
|
|||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceReplica;
|
use App\Models\ServiceReplica;
|
||||||
use App\Services\Compose\ComposeRenderer;
|
use App\Services\Compose\ComposeRenderer;
|
||||||
|
use App\Support\CaddyRouteRenderer;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -29,6 +30,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Environment $environment,
|
public Environment $environment,
|
||||||
|
public ?string $targetCommit = null,
|
||||||
) {
|
) {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
'started_at' => now(),
|
'started_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment);
|
$commitSha = $this->targetCommit ?? app(ResolveEnvironmentCommit::class)->execute($this->environment);
|
||||||
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
|
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
|
||||||
|
|
||||||
if ($services === []) {
|
if ($services === []) {
|
||||||
@@ -378,15 +380,25 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
|
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
|
||||||
{
|
{
|
||||||
$containerName = $attachment->service->replicas()->first()?->container_name;
|
$containerName = $attachment->service->replicas()->first()?->container_name;
|
||||||
|
$config = $attachment->serviceSlice?->config ?? [];
|
||||||
|
$domain = $config['domain'] ?? null;
|
||||||
|
$tlsEnabled = $config['tls_enabled'] ?? true;
|
||||||
$reloadCommand = $containerName
|
$reloadCommand = $containerName
|
||||||
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
|
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
|
||||||
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
|
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
|
||||||
|
$certificateCheck = $tlsEnabled && $domain
|
||||||
|
? 'curl --fail --silent --show-error --head https://'.escapeshellarg($domain).' >/dev/null'
|
||||||
|
: 'true # TLS disabled or no domain configured for this route';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'name' => 'Validate Caddy route configuration',
|
'name' => 'Validate Caddy route configuration',
|
||||||
'script' => 'test -s /home/keystone/gateway/Caddyfile',
|
'script' => 'test -s /home/keystone/gateway/Caddyfile',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Check TLS certificate status',
|
||||||
|
'script' => $certificateCheck,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Reload Caddy',
|
'name' => 'Reload Caddy',
|
||||||
'script' => $reloadCommand,
|
'script' => $reloadCommand,
|
||||||
@@ -406,15 +418,13 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
|
|
||||||
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
|
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
|
||||||
{
|
{
|
||||||
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
|
|
||||||
$upstreams = $this->gatewayUpstreams($attachment);
|
$upstreams = $this->gatewayUpstreams($attachment);
|
||||||
|
$caddyfile = app(CaddyRouteRenderer::class)->render($attachment, $upstreams);
|
||||||
|
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
||||||
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
|
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
|
||||||
"{$route} {",
|
$caddyfile,
|
||||||
' reverse_proxy '.implode(' ', $upstreams),
|
|
||||||
'}',
|
|
||||||
'KEYSTONE_CADDY_ROUTE',
|
'KEYSTONE_CADDY_ROUTE',
|
||||||
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
|
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class Application extends Model
|
|||||||
return $this->belongsTo(Organisation::class);
|
return $this->belongsTo(Organisation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sourceProvider(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SourceProvider::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function environments(): HasMany
|
public function environments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Environment::class);
|
return $this->hasMany(Environment::class);
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class Organisation extends Model
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invitations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OrganisationInvitation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function servers(): HasMany
|
public function servers(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Server::class);
|
return $this->hasMany(Server::class);
|
||||||
|
|||||||
35
app/Models/OrganisationInvitation.php
Normal file
35
app/Models/OrganisationInvitation.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OrganisationInvitation extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\OrganisationInvitationFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => OrganisationRole::class,
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organisation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organisation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invitedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Spatie\Ssh\Ssh;
|
use Spatie\Ssh\Ssh;
|
||||||
|
|
||||||
class Server extends Model
|
class Server extends Model
|
||||||
@@ -69,6 +70,11 @@ class Server extends Model
|
|||||||
)->where('target_type', (new Service)->getMorphClass());
|
)->where('target_type', (new Service)->getMorphClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function operations(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Operation::class, 'target');
|
||||||
|
}
|
||||||
|
|
||||||
public function sshClient(string $user = 'root'): Ssh
|
public function sshClient(string $user = 'root'): Ssh
|
||||||
{
|
{
|
||||||
return Ssh::create($user, $this->ipv4)
|
return Ssh::create($user, $this->ipv4)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Enums\SourceProviderType;
|
use App\Enums\SourceProviderType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class SourceProvider extends Model
|
class SourceProvider extends Model
|
||||||
{
|
{
|
||||||
@@ -22,4 +23,9 @@ class SourceProvider extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Organisation::class);
|
return $this->belongsTo(Organisation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function applications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Application::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Support/CaddyRouteRenderer.php
Normal file
38
app/Support/CaddyRouteRenderer.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\EnvironmentAttachment;
|
||||||
|
|
||||||
|
class CaddyRouteRenderer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $upstreams
|
||||||
|
*/
|
||||||
|
public function render(EnvironmentAttachment $attachment, array $upstreams): string
|
||||||
|
{
|
||||||
|
$config = $attachment->serviceSlice?->config ?? [];
|
||||||
|
$domain = $config['domain'] ?? $attachment->serviceSlice?->name ?? $attachment->environment->name;
|
||||||
|
$pathPrefix = $config['path_prefix'] ?? '/';
|
||||||
|
$siteAddress = ($config['tls_enabled'] ?? true) ? $domain : "http://{$domain}";
|
||||||
|
$upstreamTargets = $upstreams === [] ? ['web:80'] : $upstreams;
|
||||||
|
|
||||||
|
if ($pathPrefix === '/') {
|
||||||
|
return implode("\n", [
|
||||||
|
"{$siteAddress} {",
|
||||||
|
' reverse_proxy '.implode(' ', $upstreamTargets),
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPath = rtrim($pathPrefix, '/');
|
||||||
|
|
||||||
|
return implode("\n", [
|
||||||
|
"{$siteAddress} {",
|
||||||
|
" handle_path {$normalizedPath}* {",
|
||||||
|
' reverse_proxy '.implode(' ', $upstreamTargets),
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
composer.lock
generated
2
composer.lock
generated
@@ -9819,5 +9819,5 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
32
database/factories/OrganisationInvitationFactory.php
Normal file
32
database/factories/OrganisationInvitationFactory.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OrganisationInvitation>
|
||||||
|
*/
|
||||||
|
class OrganisationInvitationFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'organisation_id' => Organisation::factory(),
|
||||||
|
'invited_by_user_id' => User::factory(),
|
||||||
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
|
'role' => OrganisationRole::MEMBER,
|
||||||
|
'token' => Str::random(40),
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?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('applications', function (Blueprint $table) {
|
||||||
|
$table->foreignId('source_provider_id')
|
||||||
|
->nullable()
|
||||||
|
->after('organisation_id')
|
||||||
|
->constrained('source_providers')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('source_provider_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
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::create('organisation_invitations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignIdFor(User::class, 'invited_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('role');
|
||||||
|
$table->string('token')->unique();
|
||||||
|
$table->timestamp('accepted_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['organisation_id', 'email']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('organisation_invitations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -65,7 +65,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
$application->environments()->create([
|
$application->environments()->create([
|
||||||
'name' => 'Dev',
|
'name' => 'Dev',
|
||||||
'branch' => 'main',
|
'branch' => 'main',
|
||||||
'url' => 'https://dev.clipbin.hjb.dev',
|
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
296
docs/managed-registry.md
Normal file
296
docs/managed-registry.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# Managed Registry Plan
|
||||||
|
|
||||||
|
Keystone should be self-hosted first. A fresh install should include a working build and image pipeline without requiring the user to bring an external Docker registry, S3 bucket, or separate build server.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
- The Keystone control node is the default build node.
|
||||||
|
- Keystone provides a first-party managed Docker registry by default.
|
||||||
|
- The managed registry stores images on local disk first.
|
||||||
|
- The registry storage path must be configurable for mounted VPS volumes.
|
||||||
|
- External registries, S3-backed storage, and dedicated build nodes are optional advanced features.
|
||||||
|
- Multi-server deployments should work out of the box after Keystone is installed.
|
||||||
|
- Registry credentials must not be persisted in operation scripts, logs, or UI-visible output.
|
||||||
|
- Old build artifacts should be pruned automatically, retaining the latest 3 successful artifacts per environment by default.
|
||||||
|
- Build and deploy should be separate phases, even when started by one user action.
|
||||||
|
- Users should be able to connect an existing Ubuntu server as a Keystone node without using a cloud provider integration.
|
||||||
|
|
||||||
|
## Default Self-Hosted Shape
|
||||||
|
|
||||||
|
When Keystone is installed on a server, that server becomes the control node. The install process should prepare:
|
||||||
|
|
||||||
|
- Keystone application services.
|
||||||
|
- Docker and Docker Compose.
|
||||||
|
- A managed `registry:2` service.
|
||||||
|
- Local registry storage.
|
||||||
|
- Generated registry credentials.
|
||||||
|
- A default build capability on the control node.
|
||||||
|
|
||||||
|
This is separate from server provisioning. Keystone needs two scripts/flows:
|
||||||
|
|
||||||
|
- `install-keystone.sh` installs Keystone itself on the control node.
|
||||||
|
- The remote provisioning script prepares other servers so they can be managed by Keystone.
|
||||||
|
|
||||||
|
Remote provisioning should continue to install Docker, configure SSH access, prepare the `keystone` user, and link the server back to Keystone. It should not be responsible for installing the Keystone application itself.
|
||||||
|
|
||||||
|
Default settings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build node: Keystone control node
|
||||||
|
Registry: registry:2 managed by Keystone
|
||||||
|
Registry storage driver: local
|
||||||
|
Registry storage path: /home/keystone/registry/data
|
||||||
|
Image retention: latest 3 successful artifacts per environment
|
||||||
|
Auth: generated htpasswd credentials managed by Keystone
|
||||||
|
```
|
||||||
|
|
||||||
|
The install flow should allow overriding the storage path, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/keystone-registry
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets users place registry image data on a mounted VPS volume while keeping Keystone's default behavior simple.
|
||||||
|
|
||||||
|
## Default Image Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Git repository
|
||||||
|
-> Keystone control node builds Docker image
|
||||||
|
-> Keystone pushes image to the managed registry
|
||||||
|
-> Target servers pull image from the managed registry
|
||||||
|
-> Target servers run containers
|
||||||
|
```
|
||||||
|
|
||||||
|
The build node and registry are separate concepts:
|
||||||
|
|
||||||
|
- Build node: where `git clone`, `docker build`, and `docker push` run.
|
||||||
|
- Registry: where built images are stored and later pulled from.
|
||||||
|
|
||||||
|
The control node is the default build node, but users should later be able to add a dedicated build node from Keystone settings.
|
||||||
|
|
||||||
|
The running Keystone server is the control node. This does not necessarily need to be represented as a normal deploy target server at first. A lightweight installation/control-node setting may be enough until Keystone needs HA control-plane support.
|
||||||
|
|
||||||
|
If Keystone later supports HA control planes, the control node concept should become more explicit so the app can distinguish between:
|
||||||
|
|
||||||
|
- The current web/queue/scheduler node.
|
||||||
|
- The active registry host.
|
||||||
|
- The default build node.
|
||||||
|
- Runtime nodes used for deployed applications.
|
||||||
|
|
||||||
|
## Registry Exposure
|
||||||
|
|
||||||
|
The managed registry should be exposed over HTTPS where possible, ideally behind the control node's web proxy, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid defaulting to a plain `host:5000` registry if possible. Plain HTTP registries require Docker daemon insecure-registry configuration on every build and target server, which adds onboarding friction.
|
||||||
|
|
||||||
|
Target servers must be able to reach the registry URL before they can deploy images built by Keystone.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Use `registry:2` htpasswd authentication for the first version.
|
||||||
|
|
||||||
|
Keystone should:
|
||||||
|
|
||||||
|
- Generate registry credentials.
|
||||||
|
- Write the registry htpasswd file during provisioning.
|
||||||
|
- Store credentials encrypted.
|
||||||
|
- Configure build and target servers for registry access.
|
||||||
|
- Use `docker login --password-stdin` when login is needed.
|
||||||
|
|
||||||
|
Do not inline registry passwords into persisted operation scripts. Operation steps are stored and may be visible in the UI or logs.
|
||||||
|
|
||||||
|
Preferred approaches:
|
||||||
|
|
||||||
|
- Configure Docker auth on each server through a separate secure action.
|
||||||
|
- Or write root-owned / user-owned credential files on the server and have deployment scripts read from those files.
|
||||||
|
|
||||||
|
Token auth can be considered later if Keystone needs per-repository or per-server scoped credentials. It should not be part of the first implementation.
|
||||||
|
|
||||||
|
## Build Planning
|
||||||
|
|
||||||
|
Build planning should assume a default managed registry exists after install.
|
||||||
|
|
||||||
|
For the default path:
|
||||||
|
|
||||||
|
- Build strategy: build on control node.
|
||||||
|
- Registry: managed local registry.
|
||||||
|
- Artifact reference: full managed registry image reference.
|
||||||
|
|
||||||
|
Multi-server deploys should no longer block because the user has not configured an external registry. They should only block if the managed registry is missing, unhealthy, or unreachable.
|
||||||
|
|
||||||
|
External registries should remain available as an advanced override.
|
||||||
|
|
||||||
|
Build strategy should not be exposed to users as low-level values such as `target_server`, `dedicated_builder`, or `external_registry`. The UI should expose intent instead:
|
||||||
|
|
||||||
|
- Default build node.
|
||||||
|
- Specific build node.
|
||||||
|
- External registry override.
|
||||||
|
|
||||||
|
Internally, build planning can still map those choices to implementation strategies.
|
||||||
|
|
||||||
|
## Build Execution
|
||||||
|
|
||||||
|
The default build execution should:
|
||||||
|
|
||||||
|
1. Select the configured build node, defaulting to the control node.
|
||||||
|
2. Clone the application repository.
|
||||||
|
3. Render the Keystone Dockerfile.
|
||||||
|
4. Log in to the managed registry.
|
||||||
|
5. Build the image.
|
||||||
|
6. Tag the image using the managed registry reference.
|
||||||
|
7. Push the image.
|
||||||
|
8. Resolve and store the registry manifest digest.
|
||||||
|
|
||||||
|
Example flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login registry.example.com --username keystone --password-stdin
|
||||||
|
docker build --file Dockerfile.keystone --tag registry.example.com/application:aaaaaaaaaaaa .
|
||||||
|
docker push registry.example.com/application:aaaaaaaaaaaa
|
||||||
|
docker manifest inspect registry.example.com/application:aaaaaaaaaaaa
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored digest must be the registry manifest digest, not a local image ID. Digest-based pulls and registry manifest deletion depend on this being correct.
|
||||||
|
|
||||||
|
Build execution should create a build operation that can succeed or fail independently from deployment. A deployment can then depend on a successful build artifact.
|
||||||
|
|
||||||
|
## Deploy Execution
|
||||||
|
|
||||||
|
Target servers should pull immutable image references from the managed registry.
|
||||||
|
|
||||||
|
Deploy execution should:
|
||||||
|
|
||||||
|
1. Ensure the target server has registry auth configured.
|
||||||
|
2. Pull the exact image digest.
|
||||||
|
3. Render Compose with the full registry image reference.
|
||||||
|
4. Start or update containers.
|
||||||
|
|
||||||
|
Example pull reference:
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry.example.com/application@sha256:...
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose should use the full registry reference, not only `sha256:...`.
|
||||||
|
|
||||||
|
Deploy execution should be a separate operation phase from build execution. The deploy phase should consume a completed build artifact and should not be responsible for building the artifact itself.
|
||||||
|
|
||||||
|
Operations should have explicit execution targets. Inferring the SSH target only from the operation target model becomes fragile once Keystone has build nodes, registry maintenance, and runtime deployment steps.
|
||||||
|
|
||||||
|
Each operation or operation step should be able to declare where it runs:
|
||||||
|
|
||||||
|
- Control node.
|
||||||
|
- Build node.
|
||||||
|
- Runtime server.
|
||||||
|
- Specific server.
|
||||||
|
|
||||||
|
## Pruning And Retention
|
||||||
|
|
||||||
|
Default retention should keep the latest 3 successful build artifacts per environment.
|
||||||
|
|
||||||
|
Pruning should also retain:
|
||||||
|
|
||||||
|
- Any artifact currently referenced by a service's available image digest.
|
||||||
|
- Any artifact currently referenced by a service's current image digest.
|
||||||
|
- Any artifact needed for an active deployment operation.
|
||||||
|
|
||||||
|
Pruning should remove old registry manifests first, then run registry garbage collection to remove unreferenced blobs from local disk.
|
||||||
|
|
||||||
|
`registry:2` requires deletion to be enabled:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REGISTRY_STORAGE_DELETE_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Garbage collection is safest when the registry is not accepting writes. The first implementation should run cleanup during a controlled maintenance window, using a lock so pruning does not race with active builds or pushes.
|
||||||
|
|
||||||
|
Suggested cleanup flow:
|
||||||
|
|
||||||
|
1. Acquire a registry maintenance lock.
|
||||||
|
2. Find prunable artifacts by environment retention rules.
|
||||||
|
3. Delete old manifests through the registry API.
|
||||||
|
4. Stop the registry or put it in a safe maintenance state.
|
||||||
|
5. Run registry garbage collection.
|
||||||
|
6. Restart the registry.
|
||||||
|
7. Mark artifacts as pruned or delete their records.
|
||||||
|
8. Release the lock.
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
These should be optional settings, not onboarding requirements:
|
||||||
|
|
||||||
|
- Dedicated build nodes.
|
||||||
|
- S3-compatible registry storage.
|
||||||
|
- External registries such as GHCR, Gitea, Docker Hub, or generic registries.
|
||||||
|
- Separate push and pull credentials.
|
||||||
|
- Credential rotation.
|
||||||
|
- Per-server or per-repository scoped auth.
|
||||||
|
- Configurable retention per application or environment.
|
||||||
|
|
||||||
|
The first version should optimize for a self-hosted user installing Keystone on a VPS and being able to deploy with minimal additional setup.
|
||||||
|
|
||||||
|
## Existing Server Provisioning
|
||||||
|
|
||||||
|
Keystone should support connecting an existing Ubuntu server as a managed node. This is important for users running VPSs, Proxmox VMs, homelab hardware, or manually provisioned servers.
|
||||||
|
|
||||||
|
The flow should be:
|
||||||
|
|
||||||
|
1. User creates a server record in Keystone as an existing server.
|
||||||
|
2. Keystone shows a one-time provisioning command.
|
||||||
|
3. User runs the command on the server as root or a sudo-capable user.
|
||||||
|
4. The script installs Docker and required packages.
|
||||||
|
5. The script creates/configures the `keystone` user.
|
||||||
|
6. The script installs Keystone's management SSH key.
|
||||||
|
7. The script calls back to Keystone with a one-time token.
|
||||||
|
8. Keystone marks the server active.
|
||||||
|
|
||||||
|
This should sit alongside cloud-provider provisioning. Cloud providers can create the VM automatically, but the same remote preparation logic should be reused where possible.
|
||||||
|
|
||||||
|
Provisioning callbacks should not authenticate only by `server_id` or IP address. They should use a short-lived, single-use provisioning token tied to the server record.
|
||||||
|
|
||||||
|
Avoid passing sensitive values such as sudo passwords in URL query strings. Safer options include:
|
||||||
|
|
||||||
|
- Generate a short-lived provisioning token and pass only that in the URL.
|
||||||
|
- Store sensitive bootstrap data server-side and let the provisioning script exchange the one-time token for the data it needs.
|
||||||
|
- Prefer SSH key-based provider bootstrap where available instead of root password bootstrap.
|
||||||
|
- If a password must be used, pass it over SSH stdin or an encrypted job payload, not through a script URL.
|
||||||
|
|
||||||
|
The remote provisioning script can still be downloaded from Keystone, but the URL should not contain long-lived secrets or reusable credentials.
|
||||||
|
|
||||||
|
### Sudo Password Handling
|
||||||
|
|
||||||
|
Keep the current Forge-like user model for now:
|
||||||
|
|
||||||
|
- Provisioned servers have a `keystone` user.
|
||||||
|
- SSH login is key-only.
|
||||||
|
- The generated sudo password is for the human user to SSH in and run elevated commands manually.
|
||||||
|
- Keystone automation continues to use SSH key access and Docker/sudo-capable permissions as required.
|
||||||
|
|
||||||
|
This model is acceptable, but sudo password delivery should be hardened.
|
||||||
|
|
||||||
|
Laravel protections help with some leak paths:
|
||||||
|
|
||||||
|
- `ShouldBeEncrypted` protects queued job payloads.
|
||||||
|
- Encrypted casts protect stored secrets.
|
||||||
|
- Hidden model attributes avoid accidental serialization.
|
||||||
|
- PHP `#[\SensitiveParameter]` can prevent secret values appearing in stack traces.
|
||||||
|
|
||||||
|
These protections do not cover query strings, shell process arguments, rendered scripts left on disk, reverse-proxy logs, or third-party request logging.
|
||||||
|
|
||||||
|
Minimal hardening plan:
|
||||||
|
|
||||||
|
1. Keep generating a sudo password for the provisioned `keystone` user.
|
||||||
|
2. Keep flashing the sudo password to the user once after server creation.
|
||||||
|
3. Add `#[\SensitiveParameter]` to job constructor parameters such as `rootPassword` and `sudoPassword`.
|
||||||
|
4. Stop passing `sudo_password` in the provision script URL.
|
||||||
|
5. Use a short-lived, single-use provisioning token in the URL instead.
|
||||||
|
6. Store the sudo password encrypted server-side until the provisioning script is rendered or exchanged.
|
||||||
|
7. Ensure the remote provisioning script deletes itself at the end of provisioning.
|
||||||
|
8. Avoid writing the plaintext sudo password to logs or long-lived files.
|
||||||
|
|
||||||
|
The goal is to preserve the simple human-admin UX while removing avoidable secret exposure from URLs and leftover bootstrap artifacts.
|
||||||
@@ -15,14 +15,14 @@ Conventions:
|
|||||||
|
|
||||||
## 1. Global Navigation & Information Architecture
|
## 1. Global Navigation & Information Architecture
|
||||||
|
|
||||||
- **Partial — sidebar only exposes Dashboard + Servers.** `resources/js/components/AppSidebar.vue:19-37` builds a `mainNavItems` array with only `Dashboard` and `Servers`. There are no entries for `Applications`, `Operations`, `Onboarding`, or organisation `Settings`. The spec frames environments as the primary surface (§20 Phase 6: "Present environments as the primary application surface"). Add at minimum `Applications` and `Operations` items, plus a context switcher / link to onboarding while incomplete.
|
- **Partial — active header navigation is still not environment-first.** The active `AppLayout` uses `resources/js/layouts/app/AppHeaderLayout.vue`, whose header navigation (`resources/js/components/AppHeader.vue`) exposes organisation, `Applications`, and `Servers` when an organisation is selected. There are still no entries for `Operations` or `Onboarding`, and environments are only reachable after choosing an application. The inactive sidebar layout (`resources/js/components/AppSidebar.vue:19-37`) is even narrower, exposing only `Dashboard` and `Servers`. The spec frames environments as the primary surface (§20 Phase 6: "Present environments as the primary application surface"). Add an environment-primary route/navigation surface, plus `Operations` and onboarding access while setup is incomplete.
|
||||||
- **Missing — no organisation switcher.** Multiple organisations are modeled (`Organisation::members()` on `app/Models/Organisation.php`), and the dashboard already supports multiple orgs (`resources/js/pages/Dashboard.vue:8-13`). After picking an org there is no way to switch without going back to `/dashboard`. Add a switcher in the sidebar header.
|
- **Partial — organisation switcher exists only in the active header chrome.** Multiple organisations are modeled (`Organisation::members()` on `app/Models/Organisation.php`), and the active header (`resources/js/components/AppSidebarHeader.vue`) includes organisation/application/environment dropdowns. The inactive sidebar layout has no equivalent, and onboarding/operations are still absent from the switcher flow. Keep the header switcher if `AppHeaderLayout` remains the canonical layout; otherwise add parity to the sidebar chrome.
|
||||||
- **Missing — no Operations index.** Operations are the spec's audit/execution backbone (§3) but the UI only surfaces them inline on `environments/Show.vue` and `servers/Show.vue`. There is no organisation-wide operations feed for triage. Add an `operations.index` view with filters (kind, status, target).
|
- **Missing — no Operations index.** Operations are the spec's audit/execution backbone (§3) but the UI only surfaces them inline on `environments/Show.vue` and `servers/Show.vue`. There is no organisation-wide operations feed for triage. Add an `operations.index` view with filters (kind, status, target).
|
||||||
- **Missing — no global empty/help state.** A fresh org with no servers/apps has no "Get started" CTA in the sidebar; user must guess to visit `/onboarding`. Promote the onboarding link until all onboarding steps are complete.
|
- **Missing — no global empty/help state.** A fresh org with no servers/apps has no "Get started" CTA in the primary app chrome; user must guess to visit `/onboarding`. Promote the onboarding link until all onboarding steps are complete.
|
||||||
|
|
||||||
## 2. Onboarding (Spec §19)
|
## 2. Onboarding (Spec §19)
|
||||||
|
|
||||||
- **Partial — onboarding page exists but is unreachable from primary nav.** `resources/js/pages/onboarding/Show.vue` is only reachable via the URL `/organisations/{id}/onboarding`. There is no link from `Dashboard`, `AppSidebar`, or `organisations/Show.vue`. Surface a persistent banner or sidebar entry while `nextStep` is non-terminal.
|
- **Partial — onboarding page exists but is unreachable from primary nav.** `resources/js/pages/onboarding/Show.vue` is only reachable via the URL `/organisations/{id}/onboarding`. There is no link from `Dashboard`, the active header navigation, `AppSidebar`, or `organisations/Show.vue`. Surface a persistent banner or primary-nav entry while `nextStep` is non-terminal.
|
||||||
- **Partial — onboarding "Provider" step routes to organisation show.** `app/Http/Controllers/OnboardingController.php:25` sets the Provider step `href` to `organisations.show`, but the Server Providers list there (`resources/js/pages/organisations/Show.vue:202-220`) has no Add button. There is no `providers.create` route or page. Either add a `ProviderController@create` + Vue page or make the step open an inline dialog.
|
- **Partial — onboarding "Provider" step routes to organisation show.** `app/Http/Controllers/OnboardingController.php:25` sets the Provider step `href` to `organisations.show`, but the Server Providers list there (`resources/js/pages/organisations/Show.vue:202-220`) has no Add button. There is no `providers.create` route or page. Either add a `ProviderController@create` + Vue page or make the step open an inline dialog.
|
||||||
- **Missing — registry/source/server-create steps don't enforce a single org-level "default" once configured.** Spec §19 says registry is required for multi-server. The UI never blocks deployment on this — see Deployment Flow gap below.
|
- **Missing — registry/source/server-create steps don't enforce a single org-level "default" once configured.** Spec §19 says registry is required for multi-server. The UI never blocks deployment on this — see Deployment Flow gap below.
|
||||||
- **Missing — onboarding doesn't reflect deploy-key install step (§5).** The spec lists "Deploy key installation and verification" as a discrete step; onboarding shows none. Add a step gated on `applications.deploy_key_installed_at`.
|
- **Missing — onboarding doesn't reflect deploy-key install step (§5).** The spec lists "Deploy key installation and verification" as a discrete step; onboarding shows none. Add a step gated on `applications.deploy_key_installed_at`.
|
||||||
@@ -39,7 +39,7 @@ Conventions:
|
|||||||
- **Partial — deploy-key card disappears once installed.** `resources/js/pages/applications/Show.vue:46-75` only shows the deploy-key card when `application.deploy_key_public && !application.deploy_key_installed_at`. After install there is no way to view or rotate the key. Show key + `deploy_key_fingerprint` and a "Rotate" action permanently in an Application Settings tab.
|
- **Partial — deploy-key card disappears once installed.** `resources/js/pages/applications/Show.vue:46-75` only shows the deploy-key card when `application.deploy_key_public && !application.deploy_key_installed_at`. After install there is no way to view or rotate the key. Show key + `deploy_key_fingerprint` and a "Rotate" action permanently in an Application Settings tab.
|
||||||
- **Missing — no fingerprint display.** The model stores `deploy_key_fingerprint` (`app/Models/Application.php`), but the UI never renders it. Surface beside the public key for verification.
|
- **Missing — no fingerprint display.** The model stores `deploy_key_fingerprint` (`app/Models/Application.php`), but the UI never renders it. Surface beside the public key for verification.
|
||||||
- **Missing — no way to re-run `git ls-remote` verification after install.** Verify button is gated by the same conditional in `applications/Show.vue:46`. Move it to an always-available action; spec §5 implies verification can be re-run.
|
- **Missing — no way to re-run `git ls-remote` verification after install.** Verify button is gated by the same conditional in `applications/Show.vue:46`. Move it to an always-available action; spec §5 implies verification can be re-run.
|
||||||
- **Missing — application creation does not pick a source provider.** `resources/js/pages/applications/Create.vue` collects `repository_url` as a free string. Source providers exist (§5: Gitea/GitHub/generic Git) but the form never references them — users have no guidance for which provider corresponds to the URL, and `application.source_provider_id` is not captured.
|
- **Missing — application creation does not pick a source provider.** `resources/js/pages/applications/Create.vue` collects `repository_url` as a free string. Source providers exist (§5: Gitea/GitHub/generic Git) but the form never references them — users have no guidance for which provider corresponds to the URL, and the application schema/UI currently has no source-provider association.
|
||||||
- **Missing — repository type selector.** Spec lists `repository_type` (§2 Application). UI hardcodes `RepositoryType::GIT` (`app/Http/Controllers/ApplicationController.php:39`). Even if Git is the only v1 type, the form should display the resolved type.
|
- **Missing — repository type selector.** Spec lists `repository_type` (§2 Application). UI hardcodes `RepositoryType::GIT` (`app/Http/Controllers/ApplicationController.php:39`). Even if Git is the only v1 type, the form should display the resolved type.
|
||||||
|
|
||||||
## 5. Applications & Environments (Spec §2, §6, §17)
|
## 5. Applications & Environments (Spec §2, §6, §17)
|
||||||
@@ -107,7 +107,7 @@ Conventions:
|
|||||||
|
|
||||||
## 9. Servers (Spec §4)
|
## 9. Servers (Spec §4)
|
||||||
|
|
||||||
- **Broken — `<template>` block has dangling fallback.** `resources/js/pages/servers/Show.vue:217` reads `<template> Something else </template>` outside any `v-if`/`v-else-if` chain. This is a stray Vue `<template>` rendered literally for any status that isn't `active` or `provisioning` — clean up or convert to `<template v-else>`.
|
- **Broken — `<template>` block has dangling fallback.** `resources/js/pages/servers/Show.vue:217` reads `<template> Something else </template>` outside any `v-if`/`v-else-if` chain. This is an unconditional Vue template whose text child renders alongside the rest of the page, not a real status fallback. Clean up or convert to `<template v-else>`.
|
||||||
- **Partial — provisioning UI is decorative.** `servers/Show.vue:27-39` cycles through fake messages on an interval. No actual progress, no association to the running `server_provision` operation (spec §3 OperationKind). Tie the cycling messages to real `operation_steps` events.
|
- **Partial — provisioning UI is decorative.** `servers/Show.vue:27-39` cycles through fake messages on an interval. No actual progress, no association to the running `server_provision` operation (spec §3 OperationKind). Tie the cycling messages to real `operation_steps` events.
|
||||||
- **Missing — server delete / decommission.** Not wired anywhere; only `index/show/create/store` routes registered (`routes/web.php:43-47`).
|
- **Missing — server delete / decommission.** Not wired anywhere; only `index/show/create/store` routes registered (`routes/web.php:43-47`).
|
||||||
- **Missing — firewall-rule UI.** `app/Models/FirewallRule.php` and `Server::firewallRules()` exist. Spec §4 step 8 says UFW with SSH open, but additional rules (e.g. for Caddy 80/443, private network) are not surfaced. Add a Firewall tab on `servers/Show.vue`.
|
- **Missing — firewall-rule UI.** `app/Models/FirewallRule.php` and `Server::firewallRules()` exist. Spec §4 step 8 says UFW with SSH open, but additional rules (e.g. for Caddy 80/443, private network) are not surfaced. Add a Firewall tab on `servers/Show.vue`.
|
||||||
@@ -122,7 +122,7 @@ Conventions:
|
|||||||
- **Missing — operation detail page.** Spec §3 implies operations are first-class. There is no `operations.show` page. Cannot view secrets used, parent op, retry, cancel, or download logs.
|
- **Missing — operation detail page.** Spec §3 implies operations are first-class. There is no `operations.show` page. Cannot view secrets used, parent op, retry, cancel, or download logs.
|
||||||
- **Missing — retry / abort actions.** Failed operations are terminal in the UI; spec doesn't forbid retry. Add at least a "Re-run operation" button on the operation detail page.
|
- **Missing — retry / abort actions.** Failed operations are terminal in the UI; spec doesn't forbid retry. Add at least a "Re-run operation" button on the operation detail page.
|
||||||
- **Missing — operation hash / kind / target column.** `Operation::hash` is generated but never displayed; useful for support and correlation with server-side `/home/keystone/operations/<operation-hash>/` directories (spec §16).
|
- **Missing — operation hash / kind / target column.** `Operation::hash` is generated but never displayed; useful for support and correlation with server-side `/home/keystone/operations/<operation-hash>/` directories (spec §16).
|
||||||
- **Missing — live progress.** Operations require a refresh to update. Inertia v2 `WhenVisible` + polling exists in this app (used in `organisations/Show.vue:126`); apply to operations.
|
- **Missing — live progress.** Operations require a refresh to update. Inertia v2 supports polling, and this app already uses `WhenVisible` for deferred organisation settings data (`organisations/Show.vue:126`); apply polling/deferred refresh patterns to operations.
|
||||||
|
|
||||||
## 11. Build Artifacts & Registry (Spec §6)
|
## 11. Build Artifacts & Registry (Spec §6)
|
||||||
|
|
||||||
@@ -189,3 +189,153 @@ To bring the UI in line with spec without inflating scope:
|
|||||||
5. **Slice + attachment maintenance.** Edit/detach/preview env-var exports.
|
5. **Slice + attachment maintenance.** Edit/detach/preview env-var exports.
|
||||||
6. **Gateway/domain UX.** Domain input on Caddy attachment, route slice view, Caddyfile preview.
|
6. **Gateway/domain UX.** Domain input on Caddy attachment, route slice view, Caddyfile preview.
|
||||||
7. **Polish:** fix `servers/Show.vue` dangling `<template>`, fix `applications/Index.vue` `:key`, add empty states, unify script lang.
|
7. **Polish:** fix `servers/Show.vue` dangling `<template>`, fix `applications/Index.vue` `:key`, add empty states, unify script lang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Progress Tracker
|
||||||
|
|
||||||
|
This tracker is the working checklist for closing the review. It is intentionally
|
||||||
|
conservative: an item is only `Done` when there is current code evidence and at
|
||||||
|
least targeted verification.
|
||||||
|
|
||||||
|
Status key:
|
||||||
|
|
||||||
|
- `Done` - implemented and targeted verification exists.
|
||||||
|
- `Partial` - meaningful UI/code exists, but the review item is not fully satisfied.
|
||||||
|
- `In progress` - code has been started but is not yet verified or finalized.
|
||||||
|
- `Open` - no convincing implementation evidence found yet.
|
||||||
|
- `Needs audit` - likely implemented, but needs an item-level pass before closing.
|
||||||
|
|
||||||
|
### Current Caution
|
||||||
|
|
||||||
|
| Item | Status | Evidence | Next action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Repository type selector | Done | `StoreApplicationRequest`, `UpdateApplicationRequest`, `ApplicationController`, `applications/Create.vue`, and `applications/Edit.vue` validate, persist, and display `repository_type`; `ApplicationControllerTest` and `CrudUiTest` cover create/update. | Final audit only. |
|
||||||
|
| Overall completion | Done | All tracker rows are done, targeted verification is logged below, and the full test suite passes. | Final audit complete. |
|
||||||
|
|
||||||
|
### Section Checklist
|
||||||
|
|
||||||
|
| Section | Review area | Status | Evidence | Remaining work |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| 1 | Environment-first navigation | Done | `AppHeader.vue` and `AppSidebar.vue` both expose Environments, Applications, Servers, Operations, and conditional Onboarding; `EnvironmentIndexController` and `environments/Index.vue` provide the environment-first index; `NavigationUiTest` covers shared navigation context and environment listing. | Final audit only. |
|
||||||
|
| 1 | Organisation switcher parity | Done | `AppSidebarHeader.vue` provides organisation/application/environment switchers and `HandleInertiaRequests` shares organisation/application context with applications/environments loaded. | Final audit only. |
|
||||||
|
| 1 | Operations index | Done | `routes/web.php` has `operations.index`; `resources/js/pages/operations/Index.vue`; `tests/Feature/OperationsUiTest.php`. | Final audit only. |
|
||||||
|
| 1 | Global empty/help state | Done | `organisations/Show.vue` shows a primary Continue onboarding CTA for incomplete organisations; `applications/Index.vue`, `servers/Index.vue`, and `environments/Index.vue` include empty states and CTAs/help text for fresh resources. | Final audit only. |
|
||||||
|
| 2 | Onboarding reachable from primary nav | Done | `OnboardingController` sends `nextStep`; `onboarding/Show.vue` renders it; `AppHeader.vue` and `AppSidebar.vue` include Onboarding while setup counts are incomplete. | Final audit only. |
|
||||||
|
| 2 | Provider onboarding step opens usable add flow | Done | `ProviderController`, provider create route/page, onboarding/provider links. | Final audit only. |
|
||||||
|
| 2 | Registry/source/server default/precondition handling | Done | `OnboardingController` gates provider/source/registry/server/application/deploy-key steps; `OnboardingControllerTest` covers next-step progression; `EnvironmentDeploymentController` blocks multi-server deploy without a registry and app/environment deploy surfaces show registry CTAs. | Final audit only. |
|
||||||
|
| 2 | Deploy-key install onboarding step | Done | `OnboardingController` includes a `deploy-key` step that targets the first app with `deploy_key_installed_at` null and marks complete when none remain. | Final audit only. |
|
||||||
|
| 3 | Provider management | Done | `providers.create/store/destroy`, provider page/tests. | Final audit only. |
|
||||||
|
| 3 | Registry/source provider lists/edit/delete | Done | Registry/source provider index/edit/update/destroy routes/pages/tests exist. | Final audit only. |
|
||||||
|
| 3 | Organisation member management | Done | `OrganisationInvitation` model/migration/factory, member/invitation routes, `OrganisationMemberController`, `organisation-members/Index.vue`, and `OrganisationMemberControllerTest` cover existing-member add/update/remove plus pending invitation create/update/cancel. | Final audit only. |
|
||||||
|
| 3 | Registry credential rotation | Done | `registries.edit/update` present. | Final audit only. |
|
||||||
|
| 4 | Deploy key always visible, fingerprint, verify, rotate | Done | Application show/controller routes/tests include deploy key rotate and verification. | Final audit only. |
|
||||||
|
| 4 | Source provider association on applications | Done | `source_provider_id` migration/model/forms/controller/tests. | Final audit only. |
|
||||||
|
| 4 | Repository type selector | Done | Code validates/persists/displays selector; targeted app CRUD tests pass. | Final audit only. |
|
||||||
|
| 5 | Application edit/delete | Done | `applications.edit/update/destroy`, `applications/Edit.vue`, tests. | Final audit only. |
|
||||||
|
| 5 | Environment create UI | Done | `environments.create/store`, create page/routes. | Final audit only. |
|
||||||
|
| 5 | Applications index key and empty state | Done | `applications/Index.vue` uses `:key="application.id"` and has an empty-state card with a create CTA. | Final audit only. |
|
||||||
|
| 5 | Application overview deploy metadata | Done | Application show renders last deploy/current commit/image digest from services/build artifacts. | Final audit only. |
|
||||||
|
| 5 | Environment settings | Done | `environments/Edit.vue`, update request/controller for branch/status/scheduler/build config. | Final audit only. |
|
||||||
|
| 5 | Branch change / deploy specific commit | Done | `StoreEnvironmentDeploymentRequest`, `target_commit`, environment deploy form, controller/job tests. | Final audit only. |
|
||||||
|
| 5 | Build artifact view per environment | Done | `build-artifacts.index/show`, environment builds section, tests. | Final audit only. |
|
||||||
|
| 5 | Scheduler controls | Done | Environment edit and show scheduler fields. | Final audit only. |
|
||||||
|
| 5 | Migration policy controls | Done | Service edit exposes migration mode/timing/command and environment show summarizes. | Final audit only. |
|
||||||
|
| 5 | Crowded environment actions | Done | `applications/Show.vue` keeps primary Open/Deploy visible, moves secondary environment actions into a More menu, and wraps action groups for responsive layouts. | Final audit only. |
|
||||||
|
| 5 | Environment delete | Done | `environments.destroy` route/controller/page action. | Final audit only. |
|
||||||
|
| 6 | Service-by-environment scoping | Done | `environment-services.show`, service breadcrumb supports environment, tests. | Final audit only. |
|
||||||
|
| 6 | Replica detail and lifecycle actions | Done | `ServiceReplicaController`, replica show/start/stop/restart routes/pages/tests. | Final audit only. |
|
||||||
|
| 6 | Endpoint listing | Done | `services/Show.vue` endpoint card. | Final audit only. |
|
||||||
|
| 6 | Compose preview | Done | `services/Show.vue` compose path/preview card. | Final audit only. |
|
||||||
|
| 6 | Process roles editor | Done | `services/Edit.vue`, `UpdateServiceRequest`, controller update. | Final audit only. |
|
||||||
|
| 6 | Service edit missing fields | Done | Deploy policy, version track, available digest, migration config, health path, backup fields added. | Final audit only. |
|
||||||
|
| 6 | Builder category and deploy policy default display | Done | `services/Create.vue` empty state/default deploy policy display. | Final audit only. |
|
||||||
|
| 6 | Stateful update resolver, backup, acknowledgement | Done | `ServiceUpdateController::resolve`, update create page hard confirmation, tests. | Final audit only. |
|
||||||
|
| 7 | Slice index/detail/create/operations | Done | `service-slices.index/create/store/show`, `service-slices/Index.vue`, `service-slices/Show.vue`, and `ResourceDetailUiTest` cover list/detail/create plus independent operations. | Final audit only. |
|
||||||
|
| 7 | Attachment env-var preview | Done | Attachment create/edit preview blocks. | Final audit only. |
|
||||||
|
| 7 | Attachment edit/detach | Done | `environment-attachments.edit/update/destroy`, pages/tests. | Final audit only. |
|
||||||
|
| 7 | Compatibility matrix from backend | Done | Attachment controller supplies compatibility matrix. | Final audit only. |
|
||||||
|
| 7 | Primary attachment semantics | Done | Helper text added. | Final audit only. |
|
||||||
|
| 8 | Environment variables index/edit/delete/import | Done | `EnvironmentVariableController`, create/index/edit pages/tests. | Final audit only. |
|
||||||
|
| 8 | Overridable/source/slice provenance | Done | Variable forms/list expose overridable and source/slice. | Final audit only. |
|
||||||
|
| 8 | Secret/plain masking and copy | Done | Variable index reveal/copy controls. | Final audit only. |
|
||||||
|
| 9 | Server dangling fallback | Done | `servers/Show.vue` no longer has unconditional "Something else". | Final audit only. |
|
||||||
|
| 9 | Provisioning tied to real operations | Done | `servers/Show.vue` renders `OperationTimeline` for active `server_provision` operations and only uses cycling copy as a fallback when no provision operation exists. | Final audit only. |
|
||||||
|
| 9 | Server delete/decommission | Done | `servers.destroy` route/controller/UI. | Final audit only. |
|
||||||
|
| 9 | Firewall-rule UI | Done | Server show lists `firewall_rules` and includes add/remove controls; `servers.firewall-rules.store/destroy` routes and `ServerControllerTest` cover create/delete/validation. | Final audit only. |
|
||||||
|
| 9 | Credential persistence wording | Done | `servers/Show.vue` flash credential copy says Keystone uses its managed SSH key later and the password is informational for initial access only. | Final audit only. |
|
||||||
|
| 9 | Re-provision/heal action | Done | `servers.heal`, controller/test/UI. | Final audit only. |
|
||||||
|
| 9 | Service add gating explanation | Done | `servers/Show.vue` disables Add service during provisioning with `title="Services can be added after provisioning completes."`. | Final audit only. |
|
||||||
|
| 9 | Operations parent-child structure | Done | `servers/Show.vue` uses shared `OperationTimeline` for service/server operations; `OperationTimeline.vue` renders child operation counts and child operation links. | Final audit only. |
|
||||||
|
| 10 | Shared operation logs | Done | `components/operations/OperationTimeline.vue` used across pages. | Final audit only. |
|
||||||
|
| 10 | Operation detail page | Done | `operations.show`, page/tests. | Final audit only. |
|
||||||
|
| 10 | Retry/cancel/download logs | Done | `OperationController` retry/cancel/logs routes/pages/tests. | Final audit only. |
|
||||||
|
| 10 | Operation hash/kind/target columns | Done | Operation pages show hash/kind/target. | Final audit only. |
|
||||||
|
| 10 | Live progress | Done | Operations index/show use Inertia polling. | Final audit only. |
|
||||||
|
| 11 | Build artifact UI | Done | Build artifact pages and registry artifact usage. | Final audit only. |
|
||||||
|
| 11 | Registry usage/pre-deploy block | Done | Multi-server deploy blocked without registry; app and environment deploy surfaces both expose the precondition before deploy. | Final audit only. |
|
||||||
|
| 11 | Build strategy selector | Done | Environment edit exposes build strategy. | Final audit only. |
|
||||||
|
| 11 | Registry detail page | Done | `registries.show` page/tests. | Final audit only. |
|
||||||
|
| 12 | Domain / route configuration UI | Done | Gateway attachment create/edit has domain/path/TLS fields; deploy route rendering uses those values through `CaddyRouteRenderer`; dedicated `gateway.routes.index/create/store/edit/update/destroy` routes/pages manage domain route slices directly. | Final audit only. |
|
||||||
|
| 12 | TLS / certificate status view | Done | Route config stores certificate status; gateway cutover operations now include a `Check TLS certificate status` runtime step; shared `OperationTimeline.vue` displays per-step statuses. | Final audit only. |
|
||||||
|
| 12 | Caddyfile preview | Done | `CaddyRouteRenderer` feeds both deploy route scripts and `gatewayRoutePreviews` on `environments/Show.vue`; `EnvironmentControllerTest` covers rendered domain/path/upstream preview. | Final audit only. |
|
||||||
|
| 12 | Cutover visualization | Done | `environments/Show.vue` gateway cutover badges now match the actual operation sequence; `DeployEnvironmentJobTest` verifies route configure and gateway cutover child operations and step names. | Final audit only. |
|
||||||
|
| 13 | Endpoint surface | Done | Service endpoint card exists. | Final audit only. |
|
||||||
|
| 13 | Private-network membership view | Done | `ServerController@index` now supplies organisation networks with attached servers; `servers/Index.vue` renders private-network membership; `ServerControllerTest` covers network/server membership props. | Final audit only. |
|
||||||
|
| 14 | Dashboard recent ops/unhealthy services | Done | Dashboard controller/page includes recent operations and unhealthy services. | Final audit only. |
|
||||||
|
| 14 | Aggregated organisation health | Done | Organisation show health cards and roster/manage link. | Final audit only. |
|
||||||
|
| 15 | Script tag consistency | Done | Page-level scripts converted to `lang="ts"` based on prior search; `rg -n "<script setup(?! lang=\"ts\")" resources/js/pages resources/js/components -P` now returns no matches. | Final audit only. |
|
||||||
|
| 15 | Typed props | Done | `rg -n "<script setup(?! lang=\"ts\")" resources/js/pages resources/js/components -P` returns no matches after converting `ServerSelector.vue`; page/component setup scripts are now TypeScript. | Final audit only. |
|
||||||
|
| 15 | Breadcrumb depth | Done | Environment-scoped service breadcrumb added. | Final audit only. |
|
||||||
|
| 15 | Radio a11y | Done | `RadioButton.vue` supports `aria-describedby`; `servers/Create.vue` and `services/Create.vue` now attach explicit description IDs for radio options with descriptive copy. | Final audit only. |
|
||||||
|
| 15 | Colour-only status | Done | `ServiceCard.vue` renders status text alongside the color dot and now uses stronger light/dark status text colors. | Final audit only. |
|
||||||
|
| 15 | Application/server empty states | Done | `applications/Index.vue` and `servers/Index.vue` both render empty-state cards with primary CTAs. | Final audit only. |
|
||||||
|
| 16 | Backing routes/controllers list | Done | `php artisan route:list --path=environments` shows scheduler settings are covered through `environments.edit/update`; registry/source-provider index routes and server firewall-rule routes are covered; `php artisan route:list --name=gateway.routes` shows six dedicated gateway route CRUD routes. | Final audit only. |
|
||||||
|
|
||||||
|
### Suggested Next Queue
|
||||||
|
|
||||||
|
No implementation gaps remain in this tracker. Keep future work to fresh manual UI review findings or new product requirements.
|
||||||
|
|
||||||
|
### Verification Log
|
||||||
|
|
||||||
|
Recent targeted checks from this workstream:
|
||||||
|
|
||||||
|
| Command | Result | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| `php artisan test` | Passed, 231 tests / 1375 assertions | Full application regression suite after completing the UI review tracker. |
|
||||||
|
| `php artisan test tests/Feature/NavigationUiTest.php` | Passed, 3 tests / 38 assertions | Environment-first navigation context, environment index, and provider onboarding route. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after gateway cutover copy/badge alignment. |
|
||||||
|
| `php artisan test tests/Feature/DeployEnvironmentJobTest.php --filter='gateway'` | Passed, 1 test / 13 assertions | Gateway cutover sequence including TLS certificate status step. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after operation step-status display. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 71 files | PHP formatting after gateway cutover TLS step changes. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after `ServerSelector.vue` typed-props conversion. |
|
||||||
|
| `rg -n "<script setup(?! lang=\"ts\")" resources/js/pages resources/js/components -P` | No matches | Verified page/component setup scripts are TypeScript. |
|
||||||
|
| `php artisan test tests/Feature/OnboardingControllerTest.php` | Passed, 4 tests / 45 assertions | Onboarding next-step progression, registry/source/server/application gates, and deploy-key gate. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 70 files | PHP formatting after onboarding test adjustment. |
|
||||||
|
| `php artisan test tests/Feature/OrganisationMemberControllerTest.php` | Passed, 4 tests / 47 assertions | Organisation member roster plus pending invitation create/update/cancel. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after organisation invitation UI changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, fixed 1 style issue across 66 dirty PHP files | PHP formatting after organisation invitation model/migration/controller/request/test changes. |
|
||||||
|
| `php artisan test tests/Feature/ServerControllerTest.php` | Passed, 4 tests / 62 assertions | Server heal/firewall coverage plus organisation-level private-network membership on servers index. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after servers index private-network membership UI changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 66 files | PHP formatting after server index/controller/test changes. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after radio `aria-describedby` associations. |
|
||||||
|
| `php artisan test tests/Feature/EnvironmentControllerTest.php` | Passed, 3 tests / 25 assertions | Environment show, including rendered Caddyfile preview for gateway attachments. |
|
||||||
|
| `php artisan test tests/Feature/DeployEnvironmentJobTest.php --filter='gateway'` | Passed, 1 test / 13 assertions | Gateway deploy script still creates route configure and cutover operations after shared Caddy renderer change. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after Caddyfile preview UI changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 61 files | PHP formatting after Caddy renderer/controller/job/test changes. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after `ServiceCard.vue` typed props and status contrast changes. |
|
||||||
|
| `php artisan test tests/Feature/ResourceDetailUiTest.php --filter='creates and shows service slices'` | Passed, 1 test / 41 assertions | Dedicated service slice index, create, detail, and independent operations coverage. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after service slice index page/link changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 59 files | PHP formatting after service slice route/controller/test changes. |
|
||||||
|
| `php artisan route:list --path=environments` | Passed, 25 environment-related routes shown | Confirmed scheduler settings are covered by environment edit/update rather than a separate scheduler sub-resource. |
|
||||||
|
| `php artisan route:list --name=gateway.routes` | Passed, 6 routes shown | Confirmed dedicated gateway route CRUD route surface. |
|
||||||
|
| `php artisan test tests/Feature/EnvironmentAttachmentControllerTest.php` | Passed, 4 tests / 100 assertions | Managed attachments plus dedicated gateway route create/list/edit/update/delete coverage. |
|
||||||
|
| `npm run build` | Passed | Frontend compilation after gateway route CRUD pages and environment link changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed, 69 files | PHP formatting after gateway route controller/request/route/test changes. |
|
||||||
|
| `php artisan test tests/Feature/RegistryControllerTest.php tests/Feature/CrudUiTest.php` | Passed, 8 tests / 121 assertions | Registry and source-provider index routes plus CRUD coverage. |
|
||||||
|
| `php artisan test tests/Feature/ServerControllerTest.php` | Passed, 3 tests / 46 assertions | Server heal and firewall-rule create/delete/validation. |
|
||||||
|
| `php artisan test tests/Feature/ApplicationControllerTest.php tests/Feature/CrudUiTest.php` | Passed, 11 tests / 111 assertions | Repository type selector validation/persistence and application CRUD. |
|
||||||
|
| `php artisan test tests/Feature/EnvironmentDeploymentControllerTest.php` | Passed, 4 tests / 22 assertions | Registry precondition and target commit deployment. |
|
||||||
|
| `php artisan test tests/Feature/ServiceControllerTest.php tests/Feature/EnvironmentVariableControllerTest.php` | Passed, 18 tests / 134 assertions | Environment-scoped services and variable management. |
|
||||||
|
| `npm run build` | Passed in recent slices | Frontend compilation after Vue changes. |
|
||||||
|
| `vendor/bin/pint --dirty` | Passed in recent slices | PHP formatting. |
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"tools": {
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
"laravel-boost": {
|
"laravel-boost": {
|
||||||
"command": "php",
|
"type": "local",
|
||||||
"args": ["artisan", "boost:mcp"]
|
"command": ["php", "artisan", "boost:mcp"],
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,16 @@ import UserMenuContent from "@/components/UserMenuContent.vue";
|
|||||||
import { getInitials } from "@/composables/useInitials";
|
import { getInitials } from "@/composables/useInitials";
|
||||||
import type { BreadcrumbItem, NavItem } from "@/types";
|
import type { BreadcrumbItem, NavItem } from "@/types";
|
||||||
import { Link, usePage } from "@inertiajs/vue3";
|
import { Link, usePage } from "@inertiajs/vue3";
|
||||||
import { AppWindowIcon, BoltIcon, Menu, Search, ServerIcon } from "lucide-vue-next";
|
import {
|
||||||
|
AppWindowIcon,
|
||||||
|
BoltIcon,
|
||||||
|
BoxesIcon,
|
||||||
|
ClipboardListIcon,
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
ServerIcon,
|
||||||
|
WorkflowIcon,
|
||||||
|
} from "lucide-vue-next";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
const isCurrentRoute = computed(() => (url: string) => page.url === url);
|
const isCurrentRoute = computed(() => (url: string) => page.url === url || page.url.startsWith(`${url}/`));
|
||||||
|
|
||||||
const activeItemStyles = computed(
|
const activeItemStyles = computed(
|
||||||
() => (url: string) =>
|
() => (url: string) =>
|
||||||
@@ -54,20 +63,31 @@ const mainNavItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (page.props.organisation) {
|
if (page.props.organisation) {
|
||||||
|
const organisationId = page.props?.organisation?.id;
|
||||||
|
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: page.props.organisation.name,
|
title: page.props.organisation.name,
|
||||||
href: new URL(
|
href: new URL(
|
||||||
route("organisations.show", {
|
route("organisations.show", {
|
||||||
organisation: page.props?.organisation?.id,
|
organisation: organisationId,
|
||||||
}),
|
}),
|
||||||
).pathname,
|
).pathname,
|
||||||
icon: BoltIcon,
|
icon: BoltIcon,
|
||||||
});
|
});
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Environments",
|
||||||
|
href: new URL(
|
||||||
|
route("environments.index", {
|
||||||
|
organisation: organisationId,
|
||||||
|
}),
|
||||||
|
).pathname,
|
||||||
|
icon: BoxesIcon,
|
||||||
|
});
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: "Applications",
|
title: "Applications",
|
||||||
href: new URL(
|
href: new URL(
|
||||||
route("applications.index", {
|
route("applications.index", {
|
||||||
organisation: page.props?.organisation?.id,
|
organisation: organisationId,
|
||||||
}),
|
}),
|
||||||
).pathname,
|
).pathname,
|
||||||
icon: AppWindowIcon,
|
icon: AppWindowIcon,
|
||||||
@@ -76,11 +96,38 @@ if (page.props.organisation) {
|
|||||||
title: "Servers",
|
title: "Servers",
|
||||||
href: new URL(
|
href: new URL(
|
||||||
route("servers.index", {
|
route("servers.index", {
|
||||||
organisation: page.props?.organisation?.id,
|
organisation: organisationId,
|
||||||
}),
|
}),
|
||||||
).pathname,
|
).pathname,
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
});
|
});
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Operations",
|
||||||
|
href: new URL(
|
||||||
|
route("operations.index", {
|
||||||
|
organisation: organisationId,
|
||||||
|
}),
|
||||||
|
).pathname,
|
||||||
|
icon: WorkflowIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
page.props.organisation.providers_count === 0 ||
|
||||||
|
page.props.organisation.source_providers_count === 0 ||
|
||||||
|
page.props.organisation.registries_count === 0 ||
|
||||||
|
page.props.organisation.servers_count === 0 ||
|
||||||
|
page.props.organisation.applications_count === 0
|
||||||
|
) {
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Onboarding",
|
||||||
|
href: new URL(
|
||||||
|
route("onboarding.show", {
|
||||||
|
organisation: organisationId,
|
||||||
|
}),
|
||||||
|
).pathname,
|
||||||
|
icon: ClipboardListIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightNavItems: NavItem[] = [
|
const rightNavItems: NavItem[] = [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { type NavItem } from "@/types";
|
import { type NavItem } from "@/types";
|
||||||
import { Link, usePage } from "@inertiajs/vue3";
|
import { Link, usePage } from "@inertiajs/vue3";
|
||||||
import { LayoutGrid, Server } from "lucide-vue-next";
|
import { AppWindow, Boxes, ClipboardList, LayoutGrid, Server, Workflow } from "lucide-vue-next";
|
||||||
import AppLogo from "./AppLogo.vue";
|
import AppLogo from "./AppLogo.vue";
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
@@ -27,6 +27,20 @@ const mainNavItems: NavItem[] = [
|
|||||||
const organisation = usePage().props.organisation;
|
const organisation = usePage().props.organisation;
|
||||||
|
|
||||||
if (organisation) {
|
if (organisation) {
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Environments",
|
||||||
|
href: route("environments.index", {
|
||||||
|
organisation: organisation.id,
|
||||||
|
}),
|
||||||
|
icon: Boxes,
|
||||||
|
});
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Applications",
|
||||||
|
href: route("applications.index", {
|
||||||
|
organisation: organisation.id,
|
||||||
|
}),
|
||||||
|
icon: AppWindow,
|
||||||
|
});
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: "Servers",
|
title: "Servers",
|
||||||
href: route("servers.index", {
|
href: route("servers.index", {
|
||||||
@@ -34,6 +48,29 @@ if (organisation) {
|
|||||||
}),
|
}),
|
||||||
icon: Server,
|
icon: Server,
|
||||||
});
|
});
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Operations",
|
||||||
|
href: route("operations.index", {
|
||||||
|
organisation: organisation.id,
|
||||||
|
}),
|
||||||
|
icon: Workflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
organisation.providers_count === 0 ||
|
||||||
|
organisation.source_providers_count === 0 ||
|
||||||
|
organisation.registries_count === 0 ||
|
||||||
|
organisation.servers_count === 0 ||
|
||||||
|
organisation.applications_count === 0
|
||||||
|
) {
|
||||||
|
mainNavItems.push({
|
||||||
|
title: "Onboarding",
|
||||||
|
href: route("onboarding.show", {
|
||||||
|
organisation: organisation.id,
|
||||||
|
}),
|
||||||
|
icon: ClipboardList,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerNavItems: NavItem[] = [];
|
const footerNavItems: NavItem[] = [];
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const environment = usePage().props.environment ?? null;
|
|||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
v-for="org in $page.props.auth.user?.organisations"
|
v-for="org in $page.props.auth.user?.organisations"
|
||||||
|
:key="org.id"
|
||||||
:as="Link"
|
:as="Link"
|
||||||
:href="route('organisations.show', { organisation: org.id })"
|
:href="route('organisations.show', { organisation: org.id })"
|
||||||
>{{ org.name }}</DropdownMenuItem
|
>{{ org.name }}</DropdownMenuItem
|
||||||
@@ -86,6 +87,7 @@ const environment = usePage().props.environment ?? null;
|
|||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
v-for="app in organisation?.applications"
|
v-for="app in organisation?.applications"
|
||||||
|
:key="app.id"
|
||||||
:as="Link"
|
:as="Link"
|
||||||
:href="
|
:href="
|
||||||
route('applications.show', {
|
route('applications.show', {
|
||||||
@@ -128,6 +130,7 @@ const environment = usePage().props.environment ?? null;
|
|||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
v-for="env in application?.environments"
|
v-for="env in application?.environments"
|
||||||
|
:key="env.id"
|
||||||
:as="Link"
|
:as="Link"
|
||||||
:href="
|
:href="
|
||||||
route('environments.show', {
|
route('environments.show', {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
defineProps({
|
defineProps<{
|
||||||
modelValue: String,
|
modelValue?: string | number | null;
|
||||||
disabled: Boolean,
|
disabled?: boolean;
|
||||||
value: String,
|
value: string | number;
|
||||||
name: String,
|
name: string;
|
||||||
});
|
describedBy?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
function onChange(event) {
|
function onChange(event: Event): void {
|
||||||
emit("update:modelValue", event.target.value);
|
emit("update:modelValue", (event.target as HTMLInputElement).value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -23,7 +26,8 @@ function onChange(event) {
|
|||||||
:value="value"
|
:value="value"
|
||||||
class="invisible absolute inset-0"
|
class="invisible absolute inset-0"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:checked="modelValue === value"
|
:checked="String(modelValue) === String(value)"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
/>
|
/>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,23 +13,16 @@ import { LoaderCircleIcon } from "lucide-vue-next";
|
|||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
servers: {
|
servers?: Record<string, any>[];
|
||||||
type: Array,
|
serviceCategory?: keyof typeof ServiceCategory;
|
||||||
required: false,
|
}>();
|
||||||
},
|
|
||||||
serviceCategory: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
validate: (value) => {
|
|
||||||
return Object.keys(ServiceCategory).includes(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
|
|
||||||
defineEmits(["select"]);
|
defineEmits<{
|
||||||
|
select: [server: Record<string, any>];
|
||||||
|
}>();
|
||||||
|
|
||||||
watch(isOpen, () => {
|
watch(isOpen, () => {
|
||||||
if (isOpen.value && props.servers === undefined) {
|
if (isOpen.value && props.servers === undefined) {
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import ServiceCategory from "@/enums/ServiceCategory";
|
import ServiceCategory from "@/enums/ServiceCategory";
|
||||||
import ServiceStatus from "@/enums/ServiceStatus";
|
import ServiceStatus from "@/enums/ServiceStatus";
|
||||||
import ServiceType from "@/enums/ServiceType";
|
import ServiceType from "@/enums/ServiceType";
|
||||||
import { DoorOpenIcon } from "lucide-vue-next";
|
import { DoorOpenIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps({
|
withDefaults(defineProps<{
|
||||||
icon: {
|
icon?: object | Function;
|
||||||
type: [Object, Function],
|
serviceType?: string;
|
||||||
default: () => DoorOpenIcon,
|
serviceCategory?: string;
|
||||||
},
|
status?: string;
|
||||||
serviceType: {
|
}>(), {
|
||||||
type: String,
|
icon: () => DoorOpenIcon,
|
||||||
default: ServiceType.GATEWAY,
|
serviceType: ServiceType.GATEWAY,
|
||||||
},
|
serviceCategory: ServiceCategory.DATABASE,
|
||||||
serviceCategory: {
|
status: ServiceStatus.UNKNOWN,
|
||||||
type: String,
|
|
||||||
default: ServiceCategory.DATABASE,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
type: String,
|
|
||||||
default: ServiceStatus.UNKNOWN,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -39,21 +32,21 @@ defineProps({
|
|||||||
<span
|
<span
|
||||||
class="inline-block size-1 rounded-full dark:bg-zinc-500"
|
class="inline-block size-1 rounded-full dark:bg-zinc-500"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-zinc-300 dark:bg-zinc-500':
|
'bg-zinc-500 dark:bg-zinc-400':
|
||||||
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
|
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
|
||||||
'bg-green-300 dark:bg-green-500': status === ServiceStatus.RUNNING,
|
'bg-green-600 dark:bg-green-400': status === ServiceStatus.RUNNING,
|
||||||
'bg-red-300 dark:bg-red-500': status === ServiceStatus.STOPPED,
|
'bg-red-600 dark:bg-red-400': status === ServiceStatus.STOPPED,
|
||||||
'bg-yellow-300 dark:bg-yellow-500': status === ServiceStatus.INSTALLING,
|
'bg-yellow-600 dark:bg-yellow-400': status === ServiceStatus.INSTALLING,
|
||||||
}"
|
}"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
class="text-xs dark:text-zinc-500"
|
class="text-xs dark:text-zinc-400"
|
||||||
:class="{
|
:class="{
|
||||||
'text-zinc-300 dark:text-zinc-500':
|
'text-zinc-600 dark:text-zinc-400':
|
||||||
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
|
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
|
||||||
'text-green-300 dark:text-green-500': status === ServiceStatus.RUNNING,
|
'text-green-700 dark:text-green-400': status === ServiceStatus.RUNNING,
|
||||||
'text-red-300 dark:text-red-500': status === ServiceStatus.STOPPED,
|
'text-red-700 dark:text-red-400': status === ServiceStatus.STOPPED,
|
||||||
'text-yellow-300 dark:text-yellow-500': status === ServiceStatus.INSTALLING,
|
'text-yellow-700 dark:text-yellow-400': status === ServiceStatus.INSTALLING,
|
||||||
}"
|
}"
|
||||||
>{{ status.replaceAll("-", " ") }}</span
|
>{{ status.replaceAll("-", " ") }}</span
|
||||||
>
|
>
|
||||||
|
|||||||
149
resources/js/components/operations/OperationTimeline.vue
Normal file
149
resources/js/components/operations/OperationTimeline.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Link } from "@inertiajs/vue3";
|
||||||
|
import { GitCommitIcon } from "lucide-vue-next";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
operations: Record<string, any>[];
|
||||||
|
showTarget?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedStep = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
||||||
|
|
||||||
|
const targetLabel = (target?: Record<string, any> | null): string => {
|
||||||
|
if (!target) {
|
||||||
|
return "Unknown target";
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.name ?? target.hostname ?? `#${target.id}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="operation in operations"
|
||||||
|
:key="operation.id"
|
||||||
|
class="rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<GitCommitIcon class="size-4 text-muted-foreground" />
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
route('operations.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
operation: operation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{{ label(operation.kind) }}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline">{{ operation.hash }}</Badge>
|
||||||
|
<Badge
|
||||||
|
:variant="operation.status === 'completed' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ label(operation.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p v-if="showTarget" class="mt-1 text-muted-foreground">
|
||||||
|
Target: {{ targetLabel(operation.target) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ operation.steps_count ?? operation.steps?.length ?? 0 }} steps
|
||||||
|
<span v-if="operation.children_count ?? operation.children?.length">
|
||||||
|
· {{ operation.children_count ?? operation.children?.length }} child ops
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="operation.steps?.length" class="mt-3 grid gap-2 border-l pl-3">
|
||||||
|
<div
|
||||||
|
v-for="step in operation.steps"
|
||||||
|
:key="step.id"
|
||||||
|
class="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
|
||||||
|
<Badge
|
||||||
|
:variant="step.status === 'completed' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ label(step.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-if="step.error_logs_excerpt || step.logs_excerpt"
|
||||||
|
class="mt-1 max-h-20 overflow-hidden whitespace-pre-wrap text-xs text-muted-foreground"
|
||||||
|
>{{ step.error_logs_excerpt ?? step.logs_excerpt }}</pre
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="step.logs || step.error_logs"
|
||||||
|
size="xs"
|
||||||
|
variant="link"
|
||||||
|
@click="selectedStep = step"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="operation.children?.length" class="mt-3 grid gap-2 border-l pl-3">
|
||||||
|
<div
|
||||||
|
v-for="child in operation.children"
|
||||||
|
:key="child.id"
|
||||||
|
class="rounded-md bg-muted/40 p-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
route('operations.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
operation: child.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{{ label(child.kind) }}
|
||||||
|
</Link>
|
||||||
|
<Badge
|
||||||
|
:variant="child.status === 'completed' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ label(child.status) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="operations.length === 0" class="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
|
No operations recorded yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog :open="!!selectedStep" @update:open="($event) => (!$event ? (selectedStep = null) : null)">
|
||||||
|
<DialogContent class="md:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<section v-if="selectedStep?.logs">
|
||||||
|
<h3 class="text-sm font-medium">Logs</h3>
|
||||||
|
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.logs }}</pre>
|
||||||
|
</section>
|
||||||
|
<section v-if="selectedStep?.error_logs">
|
||||||
|
<h3 class="text-sm font-medium">Error Logs</h3>
|
||||||
|
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.error_logs }}</pre>
|
||||||
|
</section>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -5,12 +5,11 @@ import { type BreadcrumbItem } from "@/types";
|
|||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
import { ChevronRightIcon } from "lucide-vue-next";
|
import { ChevronRightIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps({
|
defineProps<{
|
||||||
organisations: {
|
organisations: Record<string, any>[];
|
||||||
type: Array,
|
recentOperations: Record<string, any>[];
|
||||||
required: true,
|
unhealthyServices: Record<string, any>[];
|
||||||
},
|
}>();
|
||||||
});
|
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
@@ -24,23 +23,80 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<div class="flex h-full flex-1 flex-col items-center gap-4 rounded-xl p-4">
|
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3">
|
||||||
<Card class="w-80">
|
<Card class="lg:col-span-2">
|
||||||
<CardHeader class="border-b-muted-background border-b">
|
<CardHeader class="border-b-muted-background border-b">
|
||||||
<CardTitle>Your Organisation</CardTitle>
|
<CardTitle>Organisations</CardTitle>
|
||||||
<CardDescription> Select an organisation to view its details. </CardDescription>
|
<CardDescription>Select an organisation to view its environments.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="divide-y-muted-foreground divide-y p-0">
|
<CardContent class="divide-y-muted-foreground divide-y p-0">
|
||||||
<Link
|
<Link
|
||||||
v-for="organisation in organisations"
|
v-for="organisation in organisations"
|
||||||
|
:key="organisation.id"
|
||||||
:href="route('organisations.show', { organisation: organisation.id })"
|
:href="route('organisations.show', { organisation: organisation.id })"
|
||||||
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
|
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div>{{ organisation.name }}</div>
|
<div>
|
||||||
|
<div class="font-medium">{{ organisation.name }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ organisation.applications_count }} applications ·
|
||||||
|
{{ organisation.servers_count }} servers ·
|
||||||
|
{{ organisation.services_count }} services
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ChevronRightIcon class="size-4 text-muted-foreground" />
|
<ChevronRightIcon class="size-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unhealthy services</CardTitle>
|
||||||
|
<CardDescription>Services that need attention across your organisations.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="service in unhealthyServices"
|
||||||
|
:key="service.id"
|
||||||
|
class="rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ service.name }}</div>
|
||||||
|
<div class="text-muted-foreground">{{ service.status }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="unhealthyServices.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No unhealthy services.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent operations</CardTitle>
|
||||||
|
<CardDescription>Latest service operations across your organisations.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="operation in recentOperations"
|
||||||
|
:key="operation.id"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ operation.kind.replace("_", " ") }}</div>
|
||||||
|
<div class="text-muted-foreground">{{ operation.hash }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">{{ operation.status.replace("-", " ") }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="recentOperations.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No operations recorded.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import InputError from "@/components/InputError.vue";
|
import InputError from "@/components/InputError.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, Link, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sourceProviders: Record<string, any>[];
|
||||||
|
repositoryTypes: Record<string, string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: "",
|
name: "",
|
||||||
|
source_provider_id: "",
|
||||||
|
repository_type: "git",
|
||||||
repository_url: "",
|
repository_url: "",
|
||||||
default_branch: "main",
|
default_branch: "main",
|
||||||
environment_name: "production",
|
environment_name: "production",
|
||||||
@@ -31,7 +39,7 @@ const form = useForm({
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
class="flex h-full max-w-3xl flex-1 flex-col gap-5 p-4"
|
||||||
@submit.prevent="
|
@submit.prevent="
|
||||||
form.post(
|
form.post(
|
||||||
route('applications.store', { organisation: $page.props.organisation.id }),
|
route('applications.store', { organisation: $page.props.organisation.id }),
|
||||||
@@ -42,6 +50,67 @@ const form = useForm({
|
|||||||
<h2 class="text-3xl font-bold tracking-tight">Create Application</h2>
|
<h2 class="text-3xl font-bold tracking-tight">Create Application</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Repository access</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Keystone will generate a deploy key after creation.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 text-sm">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="repository_type">Repository type</Label>
|
||||||
|
<select
|
||||||
|
id="repository_type"
|
||||||
|
v-model="form.repository_type"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(type, key) in repositoryTypes"
|
||||||
|
:key="key"
|
||||||
|
:value="type"
|
||||||
|
>
|
||||||
|
{{ type }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.repository_type" />
|
||||||
|
</div>
|
||||||
|
<div v-if="sourceProviders.length" class="grid gap-2">
|
||||||
|
<Label for="source_provider_id">Source provider</Label>
|
||||||
|
<select
|
||||||
|
id="source_provider_id"
|
||||||
|
v-model="form.source_provider_id"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">No provider</option>
|
||||||
|
<option
|
||||||
|
v-for="provider in sourceProviders"
|
||||||
|
:key="provider.id"
|
||||||
|
:value="provider.id"
|
||||||
|
>
|
||||||
|
{{ provider.name }} · {{ provider.type }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.source_provider_id" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3">
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
No source provider is configured yet. SSH URLs still work, but adding a
|
||||||
|
provider documents which Git host this repository belongs to.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:href="route('source-providers.create', { organisation: $page.props.organisation.id })"
|
||||||
|
>
|
||||||
|
Add provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="name">Name</Label>
|
<Label for="name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
142
resources/js/pages/applications/Edit.vue
Normal file
142
resources/js/pages/applications/Edit.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
repositoryTypes: Record<string, string>;
|
||||||
|
sourceProviders: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: props.application.name,
|
||||||
|
source_provider_id: props.application.source_provider_id ?? "",
|
||||||
|
repository_type: props.application.repository_type ?? "git",
|
||||||
|
repository_url: props.application.repository_url,
|
||||||
|
default_branch: props.application.default_branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroyApplication = (): void => {
|
||||||
|
if (!window.confirm(`Delete ${props.application.name}? This removes its environments too.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("applications.destroy", {
|
||||||
|
organisation: props.application.organisation_id,
|
||||||
|
application: props.application.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Edit ${application.name}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Edit' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-3xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('applications.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Edit Application</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Update repository metadata used when resolving deploy targets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Repository</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="repository_type">Repository type</Label>
|
||||||
|
<select
|
||||||
|
id="repository_type"
|
||||||
|
v-model="form.repository_type"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(type, key) in repositoryTypes"
|
||||||
|
:key="key"
|
||||||
|
:value="type"
|
||||||
|
>
|
||||||
|
{{ type }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.repository_type" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_provider_id">Source provider</Label>
|
||||||
|
<select
|
||||||
|
id="source_provider_id"
|
||||||
|
v-model="form.source_provider_id"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">No provider</option>
|
||||||
|
<option
|
||||||
|
v-for="provider in sourceProviders"
|
||||||
|
:key="provider.id"
|
||||||
|
:value="provider.id"
|
||||||
|
>
|
||||||
|
{{ provider.name }} · {{ provider.type }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.source_provider_id" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="repository_url">Repository SSH URL</Label>
|
||||||
|
<Input id="repository_url" v-model="form.repository_url" required />
|
||||||
|
<InputError :message="form.errors.repository_url" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="default_branch">Default branch</Label>
|
||||||
|
<Input id="default_branch" v-model="form.default_branch" required />
|
||||||
|
<InputError :message="form.errors.default_branch" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-2">
|
||||||
|
<Button type="button" variant="destructive" @click="destroyApplication">
|
||||||
|
Delete application
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
import { PlusIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps<{
|
||||||
applications: {
|
applications: Record<string, any>[];
|
||||||
type: [Object, null],
|
}>();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,7 +24,12 @@ const props = defineProps({
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 p-4">
|
<div class="flex items-center justify-between gap-3 p-4">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">Applications</h2>
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Applications</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Source repositories and their deployment environments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
@@ -36,6 +39,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +47,7 @@ const props = defineProps({
|
|||||||
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="application in applications"
|
v-for="application in applications"
|
||||||
:key="`application{$applications.id}`"
|
:key="application.id"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -62,6 +66,28 @@ const props = defineProps({
|
|||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
></Link>
|
></Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card v-if="applications.length === 0" class="md:col-span-2 lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No applications yet</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create an application to add the first environment, deploy key, and runtime
|
||||||
|
services.
|
||||||
|
</CardDescription>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="
|
||||||
|
route('applications.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Create application
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,22 +2,29 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, router } from "@inertiajs/vue3";
|
import { Head, Link, router } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps<{
|
||||||
application: {
|
application: Record<string, any>;
|
||||||
type: Object,
|
deploymentRequirements: Record<string, any>;
|
||||||
required: true,
|
}>();
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,45 +37,122 @@ const props = defineProps({
|
|||||||
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: props.application.name,
|
title: application.name,
|
||||||
href: route('applications.show', {
|
href: route('applications.show', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
application: props.application.id,
|
application: application.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">{{ application.name }}</h2>
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">{{ application.name }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{{ application.source_provider?.name ?? "No source provider" }} ·
|
||||||
|
{{ application.repository_type }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('applications.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="application.deploy_key_public && !application.deploy_key_installed_at">
|
<Card v-if="application.deploy_key_public">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 space-y-3">
|
<div class="min-w-0 space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<KeyRoundIcon class="size-4" />
|
<KeyRoundIcon class="size-4" />
|
||||||
<CardTitle>Repository Deploy Key</CardTitle>
|
<CardTitle>Repository Deploy Key</CardTitle>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
application.deploy_key_installed_at ? 'success' : 'secondary'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
application.deploy_key_installed_at
|
||||||
|
? "verified"
|
||||||
|
: "not verified"
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="application.deploy_key_fingerprint"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Fingerprint: {{ application.deploy_key_fingerprint }}
|
||||||
</div>
|
</div>
|
||||||
<pre
|
<pre
|
||||||
class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs"
|
class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs"
|
||||||
>{{ application.deploy_key_public }}</pre
|
>{{ application.deploy_key_public }}</pre
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
@click="
|
||||||
|
router.post(
|
||||||
|
route('applications.deploy-key.rotate', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon class="size-4" />
|
||||||
|
Rotate key
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="
|
||||||
|
router.post(
|
||||||
|
route('applications.verify-repository', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<GitBranchIcon class="size-4" />
|
||||||
|
Verify access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="deploymentRequirements.registryRequired">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Registry required before deployment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This organisation has {{ deploymentRequirements.serverCount }}
|
||||||
|
servers and no registry. Multi-server deployments need a registry
|
||||||
|
so every server can pull the same build artifact.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
class="shrink-0"
|
:as="Link"
|
||||||
@click="
|
:href="
|
||||||
router.post(
|
route('registries.create', {
|
||||||
route('applications.verify-repository', {
|
organisation: $page.props.organisation.id,
|
||||||
organisation: $page.props.organisation.id,
|
})
|
||||||
application: application.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<GitBranchIcon class="size-4" />
|
Configure registry
|
||||||
Verify
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -77,6 +161,19 @@ const props = defineProps({
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h3 class="text-2xl font-semibold tracking-tight">Environments</h3>
|
<h3 class="text-2xl font-semibold tracking-tight">Environments</h3>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
:href="
|
||||||
|
route('environments.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add environment
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
@@ -103,6 +200,38 @@ const props = defineProps({
|
|||||||
Branch: {{ environment.branch }} •
|
Branch: {{ environment.branch }} •
|
||||||
{{ environment.services?.length ?? 0 }} services
|
{{ environment.services?.length ?? 0 }} services
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
<div class="mt-3 grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
Last deploy:
|
||||||
|
{{
|
||||||
|
environment.operations?.[0]?.finished_at ??
|
||||||
|
environment.operations?.[0]?.created_at ??
|
||||||
|
"never"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Current commit:
|
||||||
|
{{
|
||||||
|
environment.services?.find(
|
||||||
|
(service) => service.desired_revision,
|
||||||
|
)?.desired_revision ?? "unknown"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Current image:
|
||||||
|
{{
|
||||||
|
environment.services?.find(
|
||||||
|
(service) =>
|
||||||
|
service.current_image_digest ||
|
||||||
|
service.available_image_digest,
|
||||||
|
)?.current_image_digest ??
|
||||||
|
environment.services?.find(
|
||||||
|
(service) => service.available_image_digest,
|
||||||
|
)?.available_image_digest ??
|
||||||
|
"unknown"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="environment.variables?.length"
|
v-if="environment.variables?.length"
|
||||||
class="mt-3 flex flex-wrap gap-2"
|
class="mt-3 flex flex-wrap gap-2"
|
||||||
@@ -120,7 +249,7 @@ const props = defineProps({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-2">
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -138,6 +267,12 @@ const props = defineProps({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
:disabled="deploymentRequirements.registryRequired"
|
||||||
|
:title="
|
||||||
|
deploymentRequirements.registryRequired
|
||||||
|
? 'Configure a registry before deploying to multiple servers.'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@click="
|
@click="
|
||||||
router.post(
|
router.post(
|
||||||
route('environment-deployments.store', {
|
route('environment-deployments.store', {
|
||||||
@@ -151,63 +286,93 @@ const props = defineProps({
|
|||||||
<RocketIcon class="size-4" />
|
<RocketIcon class="size-4" />
|
||||||
Deploy
|
Deploy
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
size="xs"
|
<DropdownMenuTrigger :as-child="true">
|
||||||
variant="secondary"
|
<Button size="xs" variant="secondary">
|
||||||
@click="
|
More
|
||||||
router.post(
|
<ChevronDownIcon class="size-4" />
|
||||||
route('environment-migrations.store', {
|
</Button>
|
||||||
organisation: $page.props.organisation.id,
|
</DropdownMenuTrigger>
|
||||||
application: application.id,
|
<DropdownMenuContent align="end">
|
||||||
environment: environment.id,
|
<DropdownMenuItem
|
||||||
}),
|
:as="Link"
|
||||||
)
|
:href="
|
||||||
"
|
route('environments.edit', {
|
||||||
>
|
organisation: $page.props.organisation.id,
|
||||||
Migrate
|
application: application.id,
|
||||||
</Button>
|
environment: environment.id,
|
||||||
<Button
|
})
|
||||||
:as="Link"
|
"
|
||||||
size="xs"
|
>
|
||||||
variant="secondary"
|
Settings
|
||||||
:href="
|
</DropdownMenuItem>
|
||||||
route('environment-variables.create', {
|
<DropdownMenuItem
|
||||||
organisation: $page.props.organisation.id,
|
@click="
|
||||||
application: application.id,
|
router.post(
|
||||||
environment: environment.id,
|
route('environment-migrations.store', {
|
||||||
})
|
organisation: $page.props.organisation.id,
|
||||||
"
|
application: application.id,
|
||||||
>
|
environment: environment.id,
|
||||||
Env
|
}),
|
||||||
</Button>
|
)
|
||||||
<Button
|
"
|
||||||
size="xs"
|
>
|
||||||
variant="secondary"
|
Migrate
|
||||||
@click="
|
</DropdownMenuItem>
|
||||||
router.post(
|
<DropdownMenuItem
|
||||||
route('environment-workers.store', {
|
:as="Link"
|
||||||
organisation: $page.props.organisation.id,
|
:href="
|
||||||
application: application.id,
|
route('environment-variables.create', {
|
||||||
environment: environment.id,
|
organisation: $page.props.organisation.id,
|
||||||
}),
|
application: application.id,
|
||||||
)
|
environment: environment.id,
|
||||||
"
|
})
|
||||||
>
|
"
|
||||||
Worker
|
>
|
||||||
</Button>
|
Variables
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
:as="Link"
|
<DropdownMenuItem
|
||||||
size="xs"
|
@click="
|
||||||
:href="
|
router.post(
|
||||||
route('environment-attachments.create', {
|
route('environment-workers.store', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
application: application.id,
|
application: application.id,
|
||||||
environment: environment.id,
|
environment: environment.id,
|
||||||
})
|
}),
|
||||||
"
|
)
|
||||||
>
|
"
|
||||||
Attach
|
>
|
||||||
</Button>
|
Add worker
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
:as="Link"
|
||||||
|
:href="
|
||||||
|
route('environment-attachments.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Attach service
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="environment.build_artifacts?.length"
|
||||||
|
class="mt-4 grid gap-2 rounded-md bg-muted/40 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">Recent builds</div>
|
||||||
|
<div
|
||||||
|
v-for="artifact in environment.build_artifacts"
|
||||||
|
:key="artifact.id"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
|
<span>{{ artifact.commit_sha }}</span>
|
||||||
|
<span v-if="artifact.image_digest">{{ artifact.image_digest }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
90
resources/js/pages/build-artifacts/Index.vue
Normal file
90
resources/js/pages/build-artifacts/Index.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
artifacts: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`${environment.name} Builds`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Builds' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Build Artifacts</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Planned and built images for this environment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Artifacts</CardTitle>
|
||||||
|
<CardDescription>{{ artifacts.data.length }} shown</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<Link
|
||||||
|
v-for="artifact in artifacts.data"
|
||||||
|
:key="artifact.id"
|
||||||
|
:href="
|
||||||
|
route('build-artifacts.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
artifact: artifact.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="rounded-md border p-3 text-sm hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
|
<span class="font-medium">{{ artifact.commit_sha }}</span>
|
||||||
|
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-muted-foreground">
|
||||||
|
{{ artifact.registry_ref ?? "No registry ref" }}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
v-if="artifacts.data.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No build artifacts recorded.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div v-if="artifacts.links?.length > 3" class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="link in artifacts.links"
|
||||||
|
:key="link.label"
|
||||||
|
:as="link.url ? Link : 'button'"
|
||||||
|
:href="link.url ?? undefined"
|
||||||
|
size="sm"
|
||||||
|
:variant="link.active ? 'default' : 'secondary'"
|
||||||
|
:disabled="!link.url"
|
||||||
|
v-html="link.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
70
resources/js/pages/build-artifacts/Show.vue
Normal file
70
resources/js/pages/build-artifacts/Show.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
artifact: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="artifact.commit_sha" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Builds',
|
||||||
|
href: route('build-artifacts.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: artifact.commit_sha },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">{{ artifact.commit_sha }}</h2>
|
||||||
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Image</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2 text-sm">
|
||||||
|
<div>Tag: {{ artifact.image_tag }}</div>
|
||||||
|
<div>Digest: {{ artifact.image_digest ?? "not available" }}</div>
|
||||||
|
<div>Registry: {{ artifact.registry_ref ?? "not pushed" }}</div>
|
||||||
|
<div>Built by service: {{ artifact.built_by_service?.name ?? "none" }}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metadata</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(artifact.metadata ?? {}, null, 2) }}</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="artifact.built_by_operation">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Build Operation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OperationTimeline :operations="[artifact.built_by_operation]" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import InputError from "@/components/InputError.vue";
|
import InputError from "@/components/InputError.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -7,24 +7,13 @@ import AppLayout from "@/layouts/AppLayout.vue";
|
|||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
import { computed, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
application: {
|
application: Record<string, any>;
|
||||||
type: Object,
|
environment: Record<string, any>;
|
||||||
required: true,
|
services: Record<string, any>[];
|
||||||
},
|
roles: string[];
|
||||||
environment: {
|
compatibility: Record<string, string[]>;
|
||||||
type: Object,
|
}>();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
service_id: props.services[0]?.id ?? null,
|
service_id: props.services[0]?.id ?? null,
|
||||||
@@ -32,18 +21,16 @@ const form = useForm({
|
|||||||
name: "",
|
name: "",
|
||||||
env_prefix: "",
|
env_prefix: "",
|
||||||
is_primary: true,
|
is_primary: true,
|
||||||
|
domain: "",
|
||||||
|
path_prefix: "/",
|
||||||
|
tls_enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const compatibleServices = computed(() => {
|
const compatibleServices = computed(() => {
|
||||||
const roleTypes = {
|
|
||||||
database: ["postgres"],
|
|
||||||
cache: ["valkey"],
|
|
||||||
queue: ["valkey"],
|
|
||||||
gateway: ["caddy"],
|
|
||||||
};
|
|
||||||
|
|
||||||
return props.services.filter((service) =>
|
return props.services.filter((service) =>
|
||||||
(roleTypes[form.role] ?? props.services.map((item) => item.type)).includes(service.type),
|
(props.compatibility[form.role] ?? props.services.map((item) => item.type)).includes(
|
||||||
|
service.type,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +53,34 @@ const generatedSliceType = computed(() => {
|
|||||||
return "service link";
|
return "service link";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const envPrefix = computed(() => form.env_prefix || form.role.toUpperCase());
|
||||||
|
const variablePreview = computed(() => {
|
||||||
|
if (form.role === "database") {
|
||||||
|
return [
|
||||||
|
`${envPrefix.value}_HOST`,
|
||||||
|
`${envPrefix.value}_PORT`,
|
||||||
|
`${envPrefix.value}_DATABASE`,
|
||||||
|
`${envPrefix.value}_USERNAME`,
|
||||||
|
`${envPrefix.value}_PASSWORD`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["cache", "queue"].includes(form.role)) {
|
||||||
|
return [
|
||||||
|
`${envPrefix.value}_HOST`,
|
||||||
|
`${envPrefix.value}_PORT`,
|
||||||
|
`${envPrefix.value}_DATABASE`,
|
||||||
|
`${envPrefix.value}_PASSWORD`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.role === "gateway") {
|
||||||
|
return ["APP_URL", "KEYSTONE_ROUTE_HOST", "KEYSTONE_ROUTE_PORT", "KEYSTONE_ROUTE_TLS"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`${envPrefix.value}_HOST`, `${envPrefix.value}_PORT`];
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
compatibleServices,
|
compatibleServices,
|
||||||
(services) => {
|
(services) => {
|
||||||
@@ -142,6 +157,19 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border bg-muted/30 p-3 text-sm">
|
||||||
|
<div class="font-medium">Environment variables preview</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<code
|
||||||
|
v-for="variable in variablePreview"
|
||||||
|
:key="variable"
|
||||||
|
class="rounded bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{{ variable }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="role">Role</Label>
|
<Label for="role">Role</Label>
|
||||||
<select
|
<select
|
||||||
@@ -180,9 +208,31 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3 md:grid-cols-3">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="domain">Domain</Label>
|
||||||
|
<Input id="domain" v-model="form.domain" type="text" placeholder="app.example.com" />
|
||||||
|
<InputError :message="form.errors.domain" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="path_prefix">Path prefix</Label>
|
||||||
|
<Input id="path_prefix" v-model="form.path_prefix" type="text" placeholder="/" />
|
||||||
|
<InputError :message="form.errors.path_prefix" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 pt-7 text-sm">
|
||||||
|
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
|
||||||
|
TLS enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input v-model="form.is_primary" type="checkbox" class="size-4" />
|
<input v-model="form.is_primary" type="checkbox" class="size-4" />
|
||||||
Primary attachment
|
<span>
|
||||||
|
Primary attachment
|
||||||
|
<span class="block text-muted-foreground">
|
||||||
|
Primary attachments provide the default unprefixed variables for this role.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
|
|||||||
137
resources/js/pages/environment-attachments/Edit.vue
Normal file
137
resources/js/pages/environment-attachments/Edit.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
attachment: Record<string, any>;
|
||||||
|
roles: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
role: props.attachment.role,
|
||||||
|
env_prefix: props.attachment.env_prefix ?? "",
|
||||||
|
is_primary: Boolean(props.attachment.is_primary),
|
||||||
|
domain: props.attachment.service_slice?.config?.domain ?? "",
|
||||||
|
path_prefix: props.attachment.service_slice?.config?.path_prefix ?? "/",
|
||||||
|
tls_enabled: props.attachment.service_slice?.config?.tls_enabled ?? true,
|
||||||
|
certificate_status: props.attachment.service_slice?.config?.certificate_status ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const detach = (): void => {
|
||||||
|
if (!window.confirm("Detach this managed service?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("environment-attachments.destroy", {
|
||||||
|
organisation: props.application.organisation_id,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
attachment: props.attachment.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Edit Attachment" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Edit Attachment' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('environment-attachments.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
attachment: attachment.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Edit Attachment</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{{ attachment.service?.name }} ·
|
||||||
|
{{ attachment.service_slice?.name ?? "service level" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="role">Role</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
v-model="form.role"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role.replace("_", " ") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.role" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="env_prefix">Env prefix</Label>
|
||||||
|
<Input id="env_prefix" v-model="form.env_prefix" placeholder="READONLY" />
|
||||||
|
<InputError :message="form.errors.env_prefix" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.is_primary" type="checkbox" class="size-4" />
|
||||||
|
Primary attachment
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="domain">Domain</Label>
|
||||||
|
<Input id="domain" v-model="form.domain" placeholder="app.example.com" />
|
||||||
|
<InputError :message="form.errors.domain" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="path_prefix">Path prefix</Label>
|
||||||
|
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" />
|
||||||
|
<InputError :message="form.errors.path_prefix" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
|
||||||
|
TLS enabled
|
||||||
|
</label>
|
||||||
|
<InputError :message="form.errors.tls_enabled" />
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="certificate_status">Certificate status</Label>
|
||||||
|
<Input
|
||||||
|
id="certificate_status"
|
||||||
|
v-model="form.certificate_status"
|
||||||
|
placeholder="pending"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.certificate_status" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-2">
|
||||||
|
<Button type="button" variant="destructive" @click="detach">Detach</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save attachment</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import InputError from "@/components/InputError.vue";
|
import InputError from "@/components/InputError.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -6,20 +6,15 @@ import { Label } from "@/components/ui/label";
|
|||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
defineProps({
|
defineProps<{
|
||||||
application: {
|
application: Record<string, any>;
|
||||||
type: Object,
|
environment: Record<string, any>;
|
||||||
required: true,
|
}>();
|
||||||
},
|
|
||||||
environment: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
key: "",
|
key: "",
|
||||||
value: "",
|
value: "",
|
||||||
|
overridable: true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,6 +69,22 @@ const form = useForm({
|
|||||||
<InputError :message="form.errors.value" />
|
<InputError :message="form.errors.value" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.overridable" type="checkbox" class="size-4" />
|
||||||
|
<span>
|
||||||
|
Overridable
|
||||||
|
<span class="block text-muted-foreground">
|
||||||
|
Allows managed attachments to replace this variable if they need to.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||||
|
Values are stored as environment variables and displayed masked in environment
|
||||||
|
overviews. Use locked variables for values that should not be replaced by generated
|
||||||
|
attachment output.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<Button type="submit" :disabled="form.processing">Save</Button>
|
<Button type="submit" :disabled="form.processing">Save</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
110
resources/js/pages/environment-variables/Edit.vue
Normal file
110
resources/js/pages/environment-variables/Edit.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
variable: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
key: props.variable.key,
|
||||||
|
value: props.variable.value ?? "",
|
||||||
|
overridable: Boolean(props.variable.overridable),
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroyVariable = (): void => {
|
||||||
|
if (!window.confirm(`Delete ${props.variable.key}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("environment-variables.destroy", {
|
||||||
|
organisation: props.application.organisation_id,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
variable: props.variable.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Edit ${variable.key}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Variables',
|
||||||
|
href: route('environment-variables.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: variable.key },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('environment-variables.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
variable: variable.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Edit Environment Variable</h2>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
|
||||||
|
{{ variable.source.replace("_", " ") }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">secret</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="key">Key</Label>
|
||||||
|
<Input id="key" v-model="form.key" required />
|
||||||
|
<InputError :message="form.errors.key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="value">Value</Label>
|
||||||
|
<Input id="value" v-model="form.value" type="password" />
|
||||||
|
<InputError :message="form.errors.value" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.overridable" type="checkbox" class="size-4" />
|
||||||
|
Overridable by managed attachments
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-2">
|
||||||
|
<Button type="button" variant="destructive" @click="destroyVariable">
|
||||||
|
Delete variable
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save variable</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
225
resources/js/pages/environment-variables/Index.vue
Normal file
225
resources/js/pages/environment-variables/Index.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
||||||
|
import { CopyIcon, EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
variables: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const importForm = useForm({
|
||||||
|
contents: "",
|
||||||
|
overridable: true,
|
||||||
|
});
|
||||||
|
const revealedVariableIds = ref<number[]>([]);
|
||||||
|
const copiedVariableId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const isRevealed = (variable: Record<string, any>): boolean =>
|
||||||
|
revealedVariableIds.value.includes(variable.id);
|
||||||
|
|
||||||
|
const toggleReveal = (variable: Record<string, any>): void => {
|
||||||
|
revealedVariableIds.value = isRevealed(variable)
|
||||||
|
? revealedVariableIds.value.filter((id) => id !== variable.id)
|
||||||
|
: [...revealedVariableIds.value, variable.id];
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyValue = async (variable: Record<string, any>): Promise<void> => {
|
||||||
|
await navigator.clipboard.writeText(variable.value ?? "");
|
||||||
|
copiedVariableId.value = variable.id;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (copiedVariableId.value === variable.id) {
|
||||||
|
copiedVariableId.value = null;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importVariables = (): void => {
|
||||||
|
importForm.post(
|
||||||
|
route("environment-variables.import", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
application: route().params.application,
|
||||||
|
environment: route().params.environment,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => importForm.reset("contents"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyVariable = (variable: Record<string, any>): void => {
|
||||||
|
if (!window.confirm(`Delete ${variable.key}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("environment-variables.destroy", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
application: route().params.application,
|
||||||
|
environment: route().params.environment,
|
||||||
|
variable: variable.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`${environment.name} Variables`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Variables' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Environment Variables</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
User values can be edited. Managed values show their source and lock state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="
|
||||||
|
route('environment-variables.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Variables</CardTitle>
|
||||||
|
<CardDescription>{{ variables.length }} configured values</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="variable in variables"
|
||||||
|
:key="variable.id"
|
||||||
|
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
route('environment-variables.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
variable: variable.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{{ variable.key }}
|
||||||
|
</Link>
|
||||||
|
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
|
||||||
|
{{ variable.source.replace('_', ' ') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-if="!variable.overridable" variant="outline">locked</Badge>
|
||||||
|
<Badge variant="outline">secret</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-muted-foreground">
|
||||||
|
{{ variable.service_slice?.name ?? "No slice source" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<code class="max-w-full truncate rounded bg-muted px-2 py-1 text-xs">
|
||||||
|
{{ isRevealed(variable) ? variable.value : "••••••••" }}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="isRevealed(variable) ? `Hide ${variable.key}` : `Reveal ${variable.key}`"
|
||||||
|
@click="toggleReveal(variable)"
|
||||||
|
>
|
||||||
|
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" />
|
||||||
|
<EyeIcon v-else class="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="`Copy ${variable.key}`"
|
||||||
|
@click="copyValue(variable)"
|
||||||
|
>
|
||||||
|
<CopyIcon class="size-3" />
|
||||||
|
<span class="sr-only">
|
||||||
|
{{ copiedVariableId === variable.id ? "Copied" : "Copy" }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="iconxs" variant="ghost" @click="destroyVariable(variable)">
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="variables.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No variables configured.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bulk Import</CardTitle>
|
||||||
|
<CardDescription>Paste KEY=value lines from an .env file.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form class="grid gap-3" @submit.prevent="importVariables">
|
||||||
|
<textarea
|
||||||
|
v-model="importForm.contents"
|
||||||
|
class="min-h-40 rounded-md border border-input bg-transparent p-3 font-mono text-sm"
|
||||||
|
placeholder="APP_ENV=production APP_DEBUG=false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="importForm.errors.contents" />
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
v-model="importForm.overridable"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
Imported values are overridable
|
||||||
|
</label>
|
||||||
|
<InputError :message="importForm.errors.overridable" />
|
||||||
|
<div>
|
||||||
|
<Button type="submit" :disabled="importForm.processing">
|
||||||
|
Import variables
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
81
resources/js/pages/environments/Create.vue
Normal file
81
resources/js/pages/environments/Create.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: "",
|
||||||
|
branch: props.application.default_branch ?? "main",
|
||||||
|
php_version: "8.4",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Create Environment" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Create Environment' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.post(
|
||||||
|
route('environments.store', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Create Environment</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
A Laravel web service is created with scheduler and migration defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required placeholder="staging" />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="branch">Branch</Label>
|
||||||
|
<Input id="branch" v-model="form.branch" required />
|
||||||
|
<InputError :message="form.errors.branch" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="php_version">PHP version</Label>
|
||||||
|
<Input id="php_version" v-model="form.php_version" required />
|
||||||
|
<InputError :message="form.errors.php_version" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button type="submit" :disabled="form.processing">Create environment</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
213
resources/js/pages/environments/Edit.vue
Normal file
213
resources/js/pages/environments/Edit.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
schedulerModes: string[];
|
||||||
|
buildStrategies: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const buildConfig = props.environment.build_config ?? {};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: props.environment.name,
|
||||||
|
branch: props.environment.branch,
|
||||||
|
status: props.environment.status,
|
||||||
|
scheduler_enabled: Boolean(props.environment.scheduler_enabled),
|
||||||
|
scheduler_target_service_id: props.environment.scheduler_target_service_id ?? "",
|
||||||
|
scheduler_mode: props.environment.scheduler_mode ?? "single",
|
||||||
|
build_strategy: buildConfig.build_strategy ?? "target_server",
|
||||||
|
php_version: buildConfig.php_version ?? "8.4",
|
||||||
|
document_root: buildConfig.document_root ?? "public",
|
||||||
|
health_path: buildConfig.health_path ?? "/up",
|
||||||
|
js_package_manager: buildConfig.js_package_manager ?? "bun",
|
||||||
|
js_build_command: buildConfig.js_build_command ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroyEnvironment = (): void => {
|
||||||
|
if (!window.confirm(`Delete ${props.environment.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("environments.destroy", {
|
||||||
|
organisation: props.application.organisation_id,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Edit ${environment.name}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Edit' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-4xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('environments.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Environment Settings</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Branch, scheduler, build strategy, and health check configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="branch">Branch</Label>
|
||||||
|
<Input id="branch" v-model="form.branch" required />
|
||||||
|
<InputError :message="form.errors.branch" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="status">Status</Label>
|
||||||
|
<Input id="status" v-model="form.status" required />
|
||||||
|
<InputError :message="form.errors.status" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scheduler</CardTitle>
|
||||||
|
<CardDescription>Choose where scheduled commands should run.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4 md:grid-cols-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.scheduler_enabled" type="checkbox" class="size-4" />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="scheduler_target_service_id">Target service</Label>
|
||||||
|
<select
|
||||||
|
id="scheduler_target_service_id"
|
||||||
|
v-model="form.scheduler_target_service_id"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">No target</option>
|
||||||
|
<option
|
||||||
|
v-for="service in environment.services"
|
||||||
|
:key="service.id"
|
||||||
|
:value="service.id"
|
||||||
|
>
|
||||||
|
{{ service.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.scheduler_target_service_id" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="scheduler_mode">Mode</Label>
|
||||||
|
<select
|
||||||
|
id="scheduler_mode"
|
||||||
|
v-model="form.scheduler_mode"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="mode in schedulerModes" :key="mode" :value="mode">
|
||||||
|
{{ mode.replace("_", " ") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.scheduler_mode" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Build & Health</CardTitle>
|
||||||
|
<CardDescription>Defaults used by deploy planning and runtime checks.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="build_strategy">Build strategy</Label>
|
||||||
|
<select
|
||||||
|
id="build_strategy"
|
||||||
|
v-model="form.build_strategy"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="strategy in buildStrategies" :key="strategy" :value="strategy">
|
||||||
|
{{ strategy.replace("_", " ") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.build_strategy" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="php_version">PHP version</Label>
|
||||||
|
<Input id="php_version" v-model="form.php_version" />
|
||||||
|
<InputError :message="form.errors.php_version" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="document_root">Document root</Label>
|
||||||
|
<Input id="document_root" v-model="form.document_root" />
|
||||||
|
<InputError :message="form.errors.document_root" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="health_path">Health path</Label>
|
||||||
|
<Input id="health_path" v-model="form.health_path" />
|
||||||
|
<InputError :message="form.errors.health_path" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="js_package_manager">JS package manager</Label>
|
||||||
|
<Input id="js_package_manager" v-model="form.js_package_manager" />
|
||||||
|
<InputError :message="form.errors.js_package_manager" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="js_build_command">JS build command</Label>
|
||||||
|
<Input id="js_build_command" v-model="form.js_build_command" />
|
||||||
|
<InputError :message="form.errors.js_build_command" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-2">
|
||||||
|
<Button type="button" variant="destructive" @click="destroyEnvironment">
|
||||||
|
Delete environment
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save settings</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
89
resources/js/pages/environments/Index.vue
Normal file
89
resources/js/pages/environments/Index.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
import { BoxesIcon, PlusIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
applications: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Environments" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Environments',
|
||||||
|
href: route('environments.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Environments</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Deployment units across all applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="route('applications.create', { organisation: $page.props.organisation.id })"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Application
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<Card v-for="application in applications" :key="application.id">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ application.name }}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ application.environments?.length ?? 0 }} environments
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Link
|
||||||
|
v-for="environment in application.environments"
|
||||||
|
:key="environment.id"
|
||||||
|
:href="
|
||||||
|
route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="rounded-md border p-3 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BoxesIcon class="size-4" />
|
||||||
|
<span class="font-medium">{{ environment.name }}</span>
|
||||||
|
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">
|
||||||
|
{{ environment.status.replace('-', ' ') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
{{ environment.branch }} · {{ environment.services_count }} services ·
|
||||||
|
{{ environment.build_artifacts_count }} builds
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="applications.every((application) => !application.environments?.length)">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No environments yet</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create an application to provision its first environment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,29 +1,67 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, router } from "@inertiajs/vue3";
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
application: {
|
application: Record<string, any>;
|
||||||
type: Object,
|
environment: Record<string, any>;
|
||||||
required: true,
|
deploymentRequirements: {
|
||||||
},
|
registryRequired: boolean;
|
||||||
environment: {
|
registryCount: number;
|
||||||
type: Object,
|
serverCount: number;
|
||||||
required: true,
|
};
|
||||||
},
|
gatewayRoutePreviews: {
|
||||||
|
attachment_id: number;
|
||||||
|
caddyfile: string;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const gatewayAttachments = computed(() =>
|
||||||
|
props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const gatewayCutovers = computed(() =>
|
||||||
|
props.environment.operations.filter((operation) => operation.kind === "gateway_cutover"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const caddyfilePreviewFor = (attachmentId: number): string =>
|
||||||
|
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)?.caddyfile ??
|
||||||
|
"# No route preview available";
|
||||||
|
|
||||||
|
const deployForm = useForm({
|
||||||
|
target_commit: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deployEnvironment = (): void => {
|
||||||
|
deployForm.post(
|
||||||
|
route("environment-deployments.store", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,21 +96,34 @@ const props = defineProps({
|
|||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
|
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Scheduler:
|
||||||
|
{{
|
||||||
|
environment.scheduler_enabled
|
||||||
|
? `${environment.scheduler_mode} on ${
|
||||||
|
environment.services?.find(
|
||||||
|
(service) =>
|
||||||
|
service.id === environment.scheduler_target_service_id,
|
||||||
|
)?.name ?? "selected service"
|
||||||
|
}`
|
||||||
|
: "disabled"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@click="
|
:as="Link"
|
||||||
router.post(
|
variant="secondary"
|
||||||
route('environment-deployments.store', {
|
:href="
|
||||||
organisation: $page.props.organisation.id,
|
route('environments.edit', {
|
||||||
application: application.id,
|
organisation: $page.props.organisation.id,
|
||||||
environment: environment.id,
|
application: application.id,
|
||||||
}),
|
environment: environment.id,
|
||||||
)
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<RocketIcon class="size-4" />
|
<PencilIcon class="size-4" />
|
||||||
Deploy
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -106,6 +157,66 @@ const props = defineProps({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Deploy Target</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Deploy the current {{ environment.branch }} branch head, or pin this
|
||||||
|
deployment to a specific commit SHA.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form class="flex flex-col gap-3 md:flex-row md:items-end" @submit.prevent="deployEnvironment">
|
||||||
|
<div class="grid flex-1 gap-2">
|
||||||
|
<Label for="target_commit">Commit SHA</Label>
|
||||||
|
<Input
|
||||||
|
id="target_commit"
|
||||||
|
v-model="deployForm.target_commit"
|
||||||
|
placeholder="Leave blank to resolve the branch head"
|
||||||
|
maxlength="40"
|
||||||
|
/>
|
||||||
|
<InputError :message="deployForm.errors.target_commit" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
|
||||||
|
:title="
|
||||||
|
deploymentRequirements.registryRequired
|
||||||
|
? 'Configure a registry before deploying to multiple servers.'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<RocketIcon class="size-4" />
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="deploymentRequirements.registryRequired" class="border-amber-200 bg-amber-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Registry Required</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This environment spans {{ deploymentRequirements.serverCount }} servers.
|
||||||
|
Configure a registry before deploying so every server can pull the same
|
||||||
|
artifact.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('registries.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Add registry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -136,14 +247,14 @@ const props = defineProps({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="service.server_id"
|
|
||||||
:as="Link"
|
:as="Link"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:href="
|
:href="
|
||||||
route('services.show', {
|
route('environment-services.show', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
server: service.server_id,
|
application: environment.application_id,
|
||||||
|
environment: environment.id,
|
||||||
service: service.id,
|
service: service.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
@@ -160,21 +271,59 @@ const props = defineProps({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Operations</CardTitle>
|
<CardTitle>Operations</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OperationTimeline :operations="environment.operations" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Builds</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Recent artifacts planned or built for this environment.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('build-artifacts.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
<CardContent class="grid gap-2">
|
<CardContent class="grid gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="operation in environment.operations"
|
v-for="artifact in environment.build_artifacts"
|
||||||
:key="operation.id"
|
:key="artifact.id"
|
||||||
class="flex items-center justify-between rounded-md border p-3"
|
class="rounded-md border p-3 text-sm"
|
||||||
>
|
>
|
||||||
<span class="font-medium">{{
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
operation.kind.replace("_", " ")
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
}}</span>
|
<span class="font-medium">{{ artifact.commit_sha }}</span>
|
||||||
<Badge
|
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
|
||||||
:variant="
|
</div>
|
||||||
operation.status === 'completed' ? 'success' : 'secondary'
|
<p class="mt-1 text-muted-foreground">
|
||||||
"
|
{{ artifact.registry_ref ?? "No registry ref" }}
|
||||||
>{{ operation.status.replace("_", " ") }}</Badge
|
<span v-if="artifact.image_digest">
|
||||||
>
|
· {{ artifact.image_digest }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="environment.build_artifacts.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No builds recorded for this environment.
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -193,16 +342,131 @@ const props = defineProps({
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 font-medium">
|
<div class="flex items-center gap-2 font-medium">
|
||||||
<DatabaseIcon class="size-4" />
|
<DatabaseIcon class="size-4" />
|
||||||
{{ attachment.role.replace("_", " ") }}
|
<Link
|
||||||
|
:href="
|
||||||
|
route('environment-attachments.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
attachment: attachment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
{{ attachment.role.replace("_", " ") }}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-muted-foreground">
|
<p class="mt-1 text-muted-foreground">
|
||||||
{{ attachment.service?.name }} ·
|
{{ attachment.service?.name }} ·
|
||||||
{{ attachment.service_slice?.name ?? "service level" }}
|
{{ attachment.service_slice?.name ?? "service level" }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="attachment.role === 'gateway'"
|
||||||
|
class="mt-2 grid gap-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Domain:
|
||||||
|
{{ attachment.service_slice?.config?.domain ?? "not set" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Path:
|
||||||
|
{{ attachment.service_slice?.config?.path_prefix ?? "/" }}
|
||||||
|
· TLS
|
||||||
|
{{
|
||||||
|
attachment.service_slice?.config?.tls_enabled === false
|
||||||
|
? "disabled"
|
||||||
|
: "enabled"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Certificate:
|
||||||
|
{{
|
||||||
|
attachment.service_slice?.config?.certificate_status ??
|
||||||
|
"pending"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="gatewayAttachments.length > 0">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Gateway Cutover</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Route validation, reload, upstream health, and drain sequence.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="xs"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('gateway.routes.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Manage routes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="attachment in gatewayAttachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
class="rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ attachment.service_slice?.config?.domain ?? "Unassigned domain" }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Caddyfile: /home/keystone/gateway/Caddyfile
|
||||||
|
</div>
|
||||||
|
<pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
|
||||||
|
caddyfilePreviewFor(attachment.id)
|
||||||
|
}}</pre>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">Render route</Badge>
|
||||||
|
<Badge variant="outline">Health check</Badge>
|
||||||
|
<Badge variant="outline">Reload gateway</Badge>
|
||||||
|
<Badge variant="outline">Drain old upstream</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OperationTimeline :operations="gatewayCutovers" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card v-else>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Gateway Routes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
No gateway routes are configured for this environment.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="xs"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('gateway.routes.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Manage routes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Variables</CardTitle>
|
<CardTitle>Variables</CardTitle>
|
||||||
@@ -221,7 +485,7 @@ const props = defineProps({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:href="
|
:href="
|
||||||
route('environment-variables.create', {
|
route('environment-variables.index', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
application: application.id,
|
application: application.id,
|
||||||
environment: environment.id,
|
environment: environment.id,
|
||||||
@@ -229,10 +493,41 @@ const props = defineProps({
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Add
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Service policy</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Migration and scheduler-related defaults exposed by current
|
||||||
|
services.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="service in environment.services"
|
||||||
|
:key="service.id"
|
||||||
|
class="rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ service.name }}</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Deploy policy: {{ service.deploy_policy ?? "default" }} ·
|
||||||
|
Roles: {{ service.process_roles?.join(", ") || "none" }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Migration:
|
||||||
|
{{
|
||||||
|
service.config?.migration_mode ??
|
||||||
|
service.config?.migration_timing ??
|
||||||
|
"not configured"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
124
resources/js/pages/gateway-routes/Create.vue
Normal file
124
resources/js/pages/gateway-routes/Create.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
services: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
service_id: props.services[0]?.id ?? null,
|
||||||
|
name: "",
|
||||||
|
domain: "",
|
||||||
|
path_prefix: "/",
|
||||||
|
tls_enabled: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Add Gateway Route" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gateway routes',
|
||||||
|
href: route('gateway.routes.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Add' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.post(
|
||||||
|
route('gateway.routes.store', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Add Gateway Route</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create a Caddy route slice for a domain and path prefix.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="service_id">Gateway service</Label>
|
||||||
|
<select
|
||||||
|
id="service_id"
|
||||||
|
v-model="form.service_id"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option v-for="service in services" :key="service.id" :value="service.id">
|
||||||
|
{{ service.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.service_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Route slice name</Label>
|
||||||
|
<Input id="name" v-model="form.name" placeholder="billing_web" required />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="domain">Domain</Label>
|
||||||
|
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
|
||||||
|
<InputError :message="form.errors.domain" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="path_prefix">Path prefix</Label>
|
||||||
|
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" required />
|
||||||
|
<InputError :message="form.errors.path_prefix" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
|
||||||
|
TLS enabled
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button type="submit" :disabled="form.processing || services.length === 0">
|
||||||
|
Create route
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
124
resources/js/pages/gateway-routes/Edit.vue
Normal file
124
resources/js/pages/gateway-routes/Edit.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
routeAttachment: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
domain: props.routeAttachment.service_slice?.config?.domain ?? "",
|
||||||
|
path_prefix: props.routeAttachment.service_slice?.config?.path_prefix ?? "/",
|
||||||
|
tls_enabled: props.routeAttachment.service_slice?.config?.tls_enabled ?? true,
|
||||||
|
certificate_status: props.routeAttachment.service_slice?.config?.certificate_status ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroyRoute = (): void => {
|
||||||
|
if (!window.confirm(`Remove gateway route ${form.domain}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("gateway.routes.destroy", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
route: props.routeAttachment.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Edit ${form.domain}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gateway routes',
|
||||||
|
href: route('gateway.routes.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Edit' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('gateway.routes.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
route: routeAttachment.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Edit Gateway Route</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{{ routeAttachment.service?.name }} ·
|
||||||
|
{{ routeAttachment.service_slice?.name ?? "route slice" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="domain">Domain</Label>
|
||||||
|
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
|
||||||
|
<InputError :message="form.errors.domain" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="path_prefix">Path prefix</Label>
|
||||||
|
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" required />
|
||||||
|
<InputError :message="form.errors.path_prefix" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
|
||||||
|
TLS enabled
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="certificate_status">Certificate status</Label>
|
||||||
|
<Input id="certificate_status" v-model="form.certificate_status" placeholder="pending" />
|
||||||
|
<InputError :message="form.errors.certificate_status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" @click="destroyRoute">Remove</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save route</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
175
resources/js/pages/gateway-routes/Index.vue
Normal file
175
resources/js/pages/gateway-routes/Index.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link, router } from "@inertiajs/vue3";
|
||||||
|
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
application: Record<string, any>;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
routes: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const destroyRoute = (routeAttachment: Record<string, any>): void => {
|
||||||
|
const domain = routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name;
|
||||||
|
|
||||||
|
if (!window.confirm(`Remove gateway route ${domain}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("gateway.routes.destroy", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
application: props.application.id,
|
||||||
|
environment: props.environment.id,
|
||||||
|
route: routeAttachment.id,
|
||||||
|
}),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`${environment.name} gateway routes`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: application.name,
|
||||||
|
href: route('applications.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: environment.name,
|
||||||
|
href: route('environments.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: 'Gateway routes' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Gateway Routes</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Domains, path prefixes, TLS state, and Caddy route slices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="
|
||||||
|
route('gateway.routes.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add route
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<Card v-for="routeAttachment in routes" :key="routeAttachment.id">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{{ routeAttachment.service_slice?.config?.domain ?? "Unassigned domain" }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ routeAttachment.service?.name }} ·
|
||||||
|
{{ routeAttachment.service_slice?.name ?? "route slice" }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ routeAttachment.service_slice?.config?.path_prefix ?? "/" }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
routeAttachment.service_slice?.config?.tls_enabled === false
|
||||||
|
? 'secondary'
|
||||||
|
: 'success'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
TLS
|
||||||
|
{{
|
||||||
|
routeAttachment.service_slice?.config?.tls_enabled === false
|
||||||
|
? "disabled"
|
||||||
|
: "enabled"
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
routeAttachment.service_slice?.config?.certificate_status ??
|
||||||
|
"pending"
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="xs"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('gateway.routes.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
route: routeAttachment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="ghost" @click="destroyRoute(routeAttachment)">
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="routes.length === 0" class="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No gateway routes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a route to connect a domain and path prefix to a Caddy gateway.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('gateway.routes.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add route
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -6,11 +6,11 @@ import AppLayout from "@/layouts/AppLayout.vue";
|
|||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
import { CheckIcon, CircleIcon } from "lucide-vue-next";
|
import { CheckIcon, CircleIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps({
|
defineProps<{
|
||||||
organisation: { type: Object, required: true },
|
organisation: Record<string, any>;
|
||||||
steps: { type: Array, required: true },
|
steps: Record<string, any>[];
|
||||||
nextStep: { type: Object, required: true },
|
nextStep: Record<string, any>;
|
||||||
});
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
98
resources/js/pages/operations/Index.vue
Normal file
98
resources/js/pages/operations/Index.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link, router, usePage, usePoll } from "@inertiajs/vue3";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
operations: Record<string, any>;
|
||||||
|
filters: Record<string, string>;
|
||||||
|
operationKinds: Record<string, string>;
|
||||||
|
operationStatuses: Record<string, string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const operationRows = computed(() => props.operations.data ?? []);
|
||||||
|
const page = usePage();
|
||||||
|
|
||||||
|
usePoll(5000, {}, { keepAlive: true });
|
||||||
|
|
||||||
|
const setFilter = (key: string, value: string | null): void => {
|
||||||
|
router.get(
|
||||||
|
route("operations.index", { organisation: page.props.organisation.id }),
|
||||||
|
{ ...props.filters, [key]: value || undefined },
|
||||||
|
{ preserveState: true, replace: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Operations" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Operations',
|
||||||
|
href: route('operations.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Operations</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Organisation-wide execution history and logs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Filters</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:variant="!filters.kind ? 'default' : 'secondary'"
|
||||||
|
@click="setFilter('kind', null)"
|
||||||
|
>
|
||||||
|
All kinds
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-for="kind in operationKinds"
|
||||||
|
:key="kind"
|
||||||
|
size="sm"
|
||||||
|
:variant="filters.kind === kind ? 'default' : 'secondary'"
|
||||||
|
@click="setFilter('kind', kind)"
|
||||||
|
>
|
||||||
|
{{ kind.replace('_', ' ') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-for="status in operationStatuses"
|
||||||
|
:key="status"
|
||||||
|
size="sm"
|
||||||
|
:variant="filters.status === status ? 'default' : 'outline'"
|
||||||
|
@click="setFilter('status', filters.status === status ? null : status)"
|
||||||
|
>
|
||||||
|
{{ status.replace('-', ' ') }}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<OperationTimeline :operations="operationRows" show-target />
|
||||||
|
|
||||||
|
<div v-if="operations.links?.length > 3" class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="link in operations.links"
|
||||||
|
:key="link.label"
|
||||||
|
:as="link.url ? Link : 'button'"
|
||||||
|
:href="link.url ?? undefined"
|
||||||
|
size="sm"
|
||||||
|
:variant="link.active ? 'default' : 'secondary'"
|
||||||
|
:disabled="!link.url"
|
||||||
|
v-html="link.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
116
resources/js/pages/operations/Show.vue
Normal file
116
resources/js/pages/operations/Show.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link, router, usePoll } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
operation: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
||||||
|
|
||||||
|
usePoll(5000, {}, { keepAlive: true });
|
||||||
|
|
||||||
|
const retryOperation = (operation: Record<string, any>): void => {
|
||||||
|
router.post(
|
||||||
|
route("operations.retry", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
operation: operation.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelOperation = (operation: Record<string, any>): void => {
|
||||||
|
router.post(
|
||||||
|
route("operations.cancel", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
operation: operation.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Operation ${operation.hash}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Operations',
|
||||||
|
href: route('operations.index', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: operation.hash },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">{{ label(operation.kind) }}</h2>
|
||||||
|
<Badge variant="outline">{{ operation.hash }}</Badge>
|
||||||
|
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">
|
||||||
|
{{ label(operation.status) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Started {{ operation.started_at ?? "not yet" }} · Finished
|
||||||
|
{{ operation.finished_at ?? "not yet" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="operation.status === 'failed'"
|
||||||
|
variant="secondary"
|
||||||
|
@click="retryOperation(operation)"
|
||||||
|
>
|
||||||
|
Re-run
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="['pending', 'in-progress'].includes(operation.status)"
|
||||||
|
variant="secondary"
|
||||||
|
@click="cancelOperation(operation)"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('operations.logs', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
operation: operation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Download logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Target</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2 text-sm">
|
||||||
|
<div>{{ operation.target?.name ?? `#${operation.target_id}` }}</div>
|
||||||
|
<div class="text-muted-foreground">{{ operation.target_type }}</div>
|
||||||
|
<Link
|
||||||
|
v-if="operation.parent"
|
||||||
|
:href="
|
||||||
|
route('operations.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
operation: operation.parent.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
Parent: {{ operation.parent.hash }}
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<OperationTimeline :operations="[operation]" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
226
resources/js/pages/organisation-members/Index.vue
Normal file
226
resources/js/pages/organisation-members/Index.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
import { Trash2Icon } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
organisation: Record<string, any>;
|
||||||
|
roles: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inviteForm = useForm({
|
||||||
|
email: "",
|
||||||
|
role: "member",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRole = (member: Record<string, any>, role: string): void => {
|
||||||
|
router.put(
|
||||||
|
route("organisation-members.update", {
|
||||||
|
organisation: props.organisation.id,
|
||||||
|
member: member.id,
|
||||||
|
}),
|
||||||
|
{ role },
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInvitationRole = (invitation: Record<string, any>, role: string): void => {
|
||||||
|
router.put(
|
||||||
|
route("organisation-invitations.update", {
|
||||||
|
organisation: props.organisation.id,
|
||||||
|
invitation: invitation.id,
|
||||||
|
}),
|
||||||
|
{ role },
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = (member: Record<string, any>): void => {
|
||||||
|
if (!window.confirm(`Remove ${member.name} from ${props.organisation.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("organisation-members.destroy", {
|
||||||
|
organisation: props.organisation.id,
|
||||||
|
member: member.id,
|
||||||
|
}),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelInvitation = (invitation: Record<string, any>): void => {
|
||||||
|
if (!window.confirm(`Cancel invitation for ${invitation.email}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("organisation-invitations.destroy", {
|
||||||
|
organisation: props.organisation.id,
|
||||||
|
invitation: invitation.id,
|
||||||
|
}),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`${organisation.name} Members`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: organisation.name,
|
||||||
|
href: route('organisations.show', { organisation: organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: 'Members' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Members</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Invite teammates, change roles, and remove access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invite Member</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Existing users are added immediately. New emails remain pending until accepted.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
class="grid gap-4 md:grid-cols-[1fr_180px_auto]"
|
||||||
|
@submit.prevent="
|
||||||
|
inviteForm.post(
|
||||||
|
route('organisation-members.store', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
}),
|
||||||
|
{ preserveScroll: true, onSuccess: () => inviteForm.reset('email') },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="email">Email</Label>
|
||||||
|
<Input id="email" v-model="inviteForm.email" type="email" required />
|
||||||
|
<InputError :message="inviteForm.errors.email" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="role">Role</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
v-model="inviteForm.role"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="inviteForm.errors.role" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button type="submit" :disabled="inviteForm.processing">Invite</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pending Invitations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ organisation.invitations.length }} pending invitations
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="invitation in organisation.invitations"
|
||||||
|
:key="invitation.id"
|
||||||
|
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-medium">{{ invitation.email }}</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Invited by
|
||||||
|
{{ invitation.invited_by?.name ?? "Keystone" }}
|
||||||
|
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
:value="invitation.role ?? 'member'"
|
||||||
|
@change="
|
||||||
|
updateInvitationRole(
|
||||||
|
invitation,
|
||||||
|
($event.target as HTMLSelectElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="`Cancel invitation for ${invitation.email}`"
|
||||||
|
@click="cancelInvitation(invitation)"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="organisation.invitations.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No pending invitations.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Roster</CardTitle>
|
||||||
|
<CardDescription>{{ organisation.members.length }} members</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="member in organisation.members"
|
||||||
|
:key="member.id"
|
||||||
|
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-medium">{{ member.name }}</div>
|
||||||
|
<div class="text-muted-foreground">{{ member.email }}</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
:value="member.membership?.role ?? 'member'"
|
||||||
|
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="member.id === organisation.owner_id"
|
||||||
|
@click="removeMember(member)"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, WhenVisible } from "@inertiajs/vue3";
|
import { Head, Link, router, WhenVisible } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
AppWindowIcon,
|
AppWindowIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
|
PencilIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
Trash2Icon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
@@ -30,6 +32,10 @@ defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
health: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabValue = ref(new URL(window.location.href).hash?.replace("#", "") || "dashboard");
|
const tabValue = ref(new URL(window.location.href).hash?.replace("#", "") || "dashboard");
|
||||||
@@ -44,6 +50,14 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const destroyResource = (url: string, label: string): void => {
|
||||||
|
if (!window.confirm(`Delete ${label}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(url, { preserveScroll: true });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -51,7 +65,22 @@ watch(
|
|||||||
|
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">{{ organisation.name }}</h2>
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">{{ organisation.name }}</h2>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
organisation.providers_count === 0 ||
|
||||||
|
organisation.source_providers_count === 0 ||
|
||||||
|
organisation.registries_count === 0 ||
|
||||||
|
organisation.servers_count === 0 ||
|
||||||
|
organisation.applications_count === 0
|
||||||
|
"
|
||||||
|
:as="Link"
|
||||||
|
:href="route('onboarding.show', { organisation: organisation.id })"
|
||||||
|
>
|
||||||
|
Continue onboarding
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Tabs v-model="tabValue" :unmount-on-hide="false">
|
<Tabs v-model="tabValue" :unmount-on-hide="false">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="dashboard"> Dashboard </TabsTrigger>
|
<TabsTrigger value="dashboard"> Dashboard </TabsTrigger>
|
||||||
@@ -121,6 +150,63 @@ watch(
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<Card class="mt-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Health</CardTitle>
|
||||||
|
<CardDescription>Aggregate signals across this organisation.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="rounded-md border p-3">
|
||||||
|
<div class="text-2xl font-semibold">{{ health.unhealthy_services }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Unhealthy services</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border p-3">
|
||||||
|
<div class="text-2xl font-semibold">{{ health.failed_operations }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Failed operations</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border p-3">
|
||||||
|
<div class="text-2xl font-semibold">{{ health.locked_variables }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Environments with locked variables</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="mt-4">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Members</CardTitle>
|
||||||
|
<CardDescription>Current organisation roster.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('organisation-members.index', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="member in organisation.members"
|
||||||
|
:key="member.id"
|
||||||
|
class="flex items-center justify-between rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ member.name }}</div>
|
||||||
|
<div class="text-muted-foreground">{{ member.email }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs uppercase text-muted-foreground">
|
||||||
|
{{ member.membership?.role ?? "member" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="settings">
|
<TabsContent value="settings">
|
||||||
<WhenVisible data="registries">
|
<WhenVisible data="registries">
|
||||||
@@ -147,10 +233,48 @@ watch(
|
|||||||
class="flex items-center gap-2 px-2 py-1"
|
class="flex items-center gap-2 px-2 py-1"
|
||||||
>
|
>
|
||||||
<ShieldCheckIcon class="size-4 text-muted-foreground" />
|
<ShieldCheckIcon class="size-4 text-muted-foreground" />
|
||||||
{{ registry.name }}
|
<Link
|
||||||
|
:href="
|
||||||
|
route('registries.show', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
{{ registry.name }}
|
||||||
|
</Link>
|
||||||
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
||||||
registry.type
|
registry.type
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:href="
|
||||||
|
route('registries.edit', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
@click="
|
||||||
|
destroyResource(
|
||||||
|
route('registries.destroy', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
}),
|
||||||
|
registry.name,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!registries?.length"
|
v-if="!registries?.length"
|
||||||
@@ -185,10 +309,35 @@ watch(
|
|||||||
class="flex items-center gap-2 px-2 py-1"
|
class="flex items-center gap-2 px-2 py-1"
|
||||||
>
|
>
|
||||||
<GitBranchIcon class="size-4 text-muted-foreground" />
|
<GitBranchIcon class="size-4 text-muted-foreground" />
|
||||||
{{ sourceProvider.name }}
|
<Link
|
||||||
|
:href="
|
||||||
|
route('source-providers.edit', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
source_provider: sourceProvider.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:underline"
|
||||||
|
>
|
||||||
|
{{ sourceProvider.name }}
|
||||||
|
</Link>
|
||||||
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
||||||
sourceProvider.type
|
sourceProvider.type
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
@click="
|
||||||
|
destroyResource(
|
||||||
|
route('source-providers.destroy', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
source_provider: sourceProvider.id,
|
||||||
|
}),
|
||||||
|
sourceProvider.name,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!sourceProviders?.length"
|
v-if="!sourceProviders?.length"
|
||||||
@@ -201,21 +350,53 @@ watch(
|
|||||||
</WhenVisible>
|
</WhenVisible>
|
||||||
<WhenVisible data="providers">
|
<WhenVisible data="providers">
|
||||||
<template #fallback> Loading... </template>
|
<template #fallback> Loading... </template>
|
||||||
<h3 class="mt-4 text-2xl font-bold tracking-tight">Server Providers</h3>
|
<div class="mt-4 flex items-center justify-between gap-3">
|
||||||
<p class="mb-4 text-sm text-muted-foreground">
|
<div>
|
||||||
Manage your server providers.
|
<h3 class="text-2xl font-bold tracking-tight">Server Providers</h3>
|
||||||
</p>
|
<p class="mb-4 text-sm text-muted-foreground">
|
||||||
|
Manage your server providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="route('providers.create', { organisation: organisation.id })"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border"
|
class="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="provider in providers"
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
class="flex items-center gap-2 px-2 py-1"
|
class="flex items-center gap-2 px-2 py-1"
|
||||||
>
|
>
|
||||||
{{ provider.name }}
|
{{ provider.name }}
|
||||||
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
|
||||||
provider.type
|
provider.type
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
@click="
|
||||||
|
destroyResource(
|
||||||
|
route('providers.destroy', {
|
||||||
|
organisation: organisation.id,
|
||||||
|
provider: provider.id,
|
||||||
|
}),
|
||||||
|
provider.name,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!providers?.length"
|
||||||
|
class="px-2 py-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No server providers configured
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</WhenVisible>
|
</WhenVisible>
|
||||||
|
|||||||
77
resources/js/pages/providers/Create.vue
Normal file
77
resources/js/pages/providers/Create.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
providerTypes: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: "",
|
||||||
|
type: "hetzner",
|
||||||
|
token: "",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Add Server Provider" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: $page.props.organisation.name,
|
||||||
|
href: route('organisations.show', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: 'Add Server Provider' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.post(route('providers.store', { organisation: $page.props.organisation.id }))
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Add Server Provider</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Provider credentials are encrypted and used to create servers and private
|
||||||
|
networks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required placeholder="Hetzner production" />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
v-model="form.type"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="type in providerTypes" :key="type" :value="type">
|
||||||
|
{{ type.replace("-", " ") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.type" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="token">API token</Label>
|
||||||
|
<Input id="token" v-model="form.token" type="password" required />
|
||||||
|
<InputError :message="form.errors.token" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button type="submit" :disabled="form.processing">Save provider</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import InputError from "@/components/InputError.vue";
|
import InputError from "@/components/InputError.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -6,12 +6,9 @@ import { Label } from "@/components/ui/label";
|
|||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
defineProps({
|
defineProps<{
|
||||||
registryTypes: {
|
registryTypes: string[];
|
||||||
type: Array,
|
}>();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: "",
|
name: "",
|
||||||
|
|||||||
118
resources/js/pages/registries/Edit.vue
Normal file
118
resources/js/pages/registries/Edit.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
registry: Record<string, any>;
|
||||||
|
registryTypes: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: props.registry.name,
|
||||||
|
type: props.registry.type,
|
||||||
|
url: props.registry.url,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const destroyRegistry = (): void => {
|
||||||
|
if (!window.confirm(`Delete ${props.registry.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("registries.destroy", {
|
||||||
|
organisation: props.registry.organisation_id,
|
||||||
|
registry: props.registry.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Edit ${registry.name}`" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Organisation',
|
||||||
|
href: route('organisations.show', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: 'Edit Registry' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
|
||||||
|
@submit.prevent="
|
||||||
|
form.put(
|
||||||
|
route('registries.update', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Edit Registry</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Leave password blank to keep the current credential.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
v-model="form.type"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType">
|
||||||
|
{{ registryType.replace("_", " ") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="form.errors.type" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="url">Registry URL</Label>
|
||||||
|
<Input id="url" v-model="form.url" required />
|
||||||
|
<InputError :message="form.errors.url" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="username">Username</Label>
|
||||||
|
<Input id="username" v-model="form.username" autocomplete="username" />
|
||||||
|
<InputError :message="form.errors.username" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="password">New password/token</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between gap-2">
|
||||||
|
<Button type="button" variant="destructive" @click="destroyRegistry">
|
||||||
|
Delete registry
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">Save registry</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
118
resources/js/pages/registries/Index.vue
Normal file
118
resources/js/pages/registries/Index.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link, router } from "@inertiajs/vue3";
|
||||||
|
import { EyeIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
registries: Record<string, any>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const destroyRegistry = (registry: Record<string, any>): void => {
|
||||||
|
if (!window.confirm(`Delete ${registry.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("registries.destroy", {
|
||||||
|
organisation: route().params.organisation,
|
||||||
|
registry: registry.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Registries" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Organisation',
|
||||||
|
href: route('organisations.show', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: 'Registries' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Registries</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Container registries used for multi-server environment deployments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="route('registries.create', { organisation: $page.props.organisation.id })"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add registry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configured Registries</CardTitle>
|
||||||
|
<CardDescription>{{ registries.length }} registries</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="registry in registries"
|
||||||
|
:key="registry.id"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-medium">{{ registry.name }}</span>
|
||||||
|
<Badge variant="outline">{{ registry.type?.replace("_", " ") }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-muted-foreground">
|
||||||
|
{{ registry.url ?? "No registry URL configured" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:href="
|
||||||
|
route('registries.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<EyeIcon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:href="
|
||||||
|
route('registries.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="iconxs" variant="ghost" @click="destroyRegistry(registry)">
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="registries.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No registries configured.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
118
resources/js/pages/registries/Show.vue
Normal file
118
resources/js/pages/registries/Show.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
import { PencilIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
registry: Record<string, any>;
|
||||||
|
artifactCount: number;
|
||||||
|
environmentCount: number;
|
||||||
|
artifacts: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="registry.name" />
|
||||||
|
|
||||||
|
<AppLayout
|
||||||
|
:breadcrumbs="[
|
||||||
|
{
|
||||||
|
title: 'Organisation',
|
||||||
|
href: route('organisations.show', { organisation: $page.props.organisation.id }),
|
||||||
|
},
|
||||||
|
{ title: registry.name },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">{{ registry.name }}</h2>
|
||||||
|
<Badge variant="outline">{{ registry.type.replace("_", " ") }}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{{ registry.url ?? "No registry URL configured" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('registries.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
registry: registry.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Artifacts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="text-3xl font-semibold">{{ artifactCount }}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Environments</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="text-3xl font-semibold">{{ environmentCount }}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Credential</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="text-sm text-muted-foreground">
|
||||||
|
Stored encrypted. Rotate it from registry settings.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Published Artifacts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Artifacts whose registry reference starts with this registry URL.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<Link
|
||||||
|
v-for="artifact in artifacts.data"
|
||||||
|
:key="artifact.id"
|
||||||
|
:href="
|
||||||
|
route('build-artifacts.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: artifact.environment.application.id,
|
||||||
|
environment: artifact.environment.id,
|
||||||
|
artifact: artifact.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="rounded-md border p-3 text-sm hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
|
<span class="font-medium">{{ artifact.environment.name }}</span>
|
||||||
|
<span class="text-muted-foreground">{{ artifact.commit_sha }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-muted-foreground">
|
||||||
|
{{ artifact.registry_ref ?? "No registry ref" }}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
v-if="artifacts.data.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No artifacts have been published to this registry.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import RadioButton from "@/components/RadioButton.vue";
|
import RadioButton from "@/components/RadioButton.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
import { watch } from "vue";
|
import { watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
providers: Array,
|
providers?: Record<string, any>[];
|
||||||
locations: Array,
|
locations?: Record<string, any>[];
|
||||||
serverTypes: Array,
|
serverTypes?: Record<string, any>[];
|
||||||
images: Array,
|
images?: Record<string, any>[];
|
||||||
});
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
provider: null,
|
provider: null,
|
||||||
@@ -30,7 +30,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => form.location,
|
() => form.location,
|
||||||
(location) => {
|
(location) => {
|
||||||
const selectedLoc = props.locations.find((loc) => loc.id === location)?.networkZone;
|
const selectedLoc = props.locations?.find((loc) => loc.id === location)?.networkZone;
|
||||||
form.network_zone = selectedLoc;
|
form.network_zone = selectedLoc;
|
||||||
loadServerTypes();
|
loadServerTypes();
|
||||||
},
|
},
|
||||||
@@ -82,9 +82,10 @@ function loadServerTypes() {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Cloud provider">
|
||||||
<RadioButton
|
<RadioButton
|
||||||
v-for="provider in providers"
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
v-model="form.provider"
|
v-model="form.provider"
|
||||||
:value="provider.id"
|
:value="provider.id"
|
||||||
:disabled="provider.disabled"
|
:disabled="provider.disabled"
|
||||||
@@ -93,9 +94,15 @@ function loadServerTypes() {
|
|||||||
{{ provider.name }}
|
{{ provider.name }}
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="form.provider" class="flex flex-wrap gap-2">
|
<div
|
||||||
|
v-if="form.provider"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Server location"
|
||||||
|
>
|
||||||
<RadioButton
|
<RadioButton
|
||||||
v-for="location in locations"
|
v-for="location in locations"
|
||||||
|
:key="location.id"
|
||||||
v-model="form.location"
|
v-model="form.location"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:disabled="location.disabled"
|
:disabled="location.disabled"
|
||||||
@@ -107,26 +114,36 @@ function loadServerTypes() {
|
|||||||
<div
|
<div
|
||||||
v-if="form.location"
|
v-if="form.location"
|
||||||
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Server type"
|
||||||
>
|
>
|
||||||
<RadioButton
|
<RadioButton
|
||||||
v-for="serverType in serverTypes?.sort((a, b) => a.cores - b.cores) ?? []"
|
v-for="serverType in serverTypes?.sort((a, b) => a.cores - b.cores) ?? []"
|
||||||
|
:key="serverType.id"
|
||||||
v-model="form.server_type"
|
v-model="form.server_type"
|
||||||
:value="serverType.id"
|
:value="serverType.id"
|
||||||
:disabled="serverType.disabled"
|
:disabled="serverType.disabled"
|
||||||
name="server-type"
|
name="server-type"
|
||||||
|
:described-by="`server-type-${serverType.id}-description`"
|
||||||
>
|
>
|
||||||
<h5 class="text-lg font-semibold uppercase tracking-tight">
|
<h5 class="text-lg font-semibold uppercase tracking-tight">
|
||||||
{{ serverType.name }}
|
{{ serverType.name }}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-sm opacity-60">
|
<p :id="`server-type-${serverType.id}-description`" class="text-sm opacity-60">
|
||||||
{{ serverType.cores }} cores • {{ serverType.memory }} GB RAM •
|
{{ serverType.cores }} cores • {{ serverType.memory }} GB RAM •
|
||||||
{{ serverType.disk }} GB disk
|
{{ serverType.disk }} GB disk
|
||||||
</p>
|
</p>
|
||||||
</RadioButton>
|
</RadioButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="form.server_type" class="flex flex-wrap gap-2">
|
<div
|
||||||
|
v-if="form.server_type"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Server image"
|
||||||
|
>
|
||||||
<RadioButton
|
<RadioButton
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
|
:key="image.id"
|
||||||
v-model="form.image"
|
v-model="form.image"
|
||||||
:value="image.id"
|
:value="image.id"
|
||||||
:disabled="image.disabled"
|
:disabled="image.disabled"
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
import { PlusIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps<{
|
||||||
servers: {
|
servers: Record<string, any>;
|
||||||
type: [Object, null],
|
networks: Record<string, any>[];
|
||||||
required: true,
|
}>();
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,7 +26,12 @@ const props = defineProps({
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 p-4">
|
<div class="flex items-center justify-between gap-3 p-4">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
|
<div>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Compute nodes, private networking, firewall status, and hosted services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
@@ -36,14 +40,16 @@ const props = defineProps({
|
|||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>Create</Button
|
|
||||||
>
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="server in servers.data"
|
v-for="server in servers.data"
|
||||||
:key="`server{$servers.id}`"
|
:key="server.id"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -65,6 +71,91 @@ const props = defineProps({
|
|||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
></Link>
|
></Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card v-if="servers.data.length === 0" class="md:col-span-2 lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No servers yet</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create the first server or continue onboarding to configure providers,
|
||||||
|
source access, and registry details.
|
||||||
|
</CardDescription>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
:href="
|
||||||
|
route('servers.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Create server
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('onboarding.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Onboarding
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="grid gap-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold tracking-tight">Private Networks</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Provider network zones and the servers attached to each private range.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card v-for="network in networks" :key="network.id">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ network.name }}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ network.ip_range }} · {{ network.network_zone }} ·
|
||||||
|
{{ network.servers.length }} servers
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<div class="grid gap-2 px-6 pb-6">
|
||||||
|
<Link
|
||||||
|
v-for="server in network.servers"
|
||||||
|
:key="server.id"
|
||||||
|
:href="
|
||||||
|
route('servers.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
server: server.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ server.name }}</span>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{{ server.private_ip ?? "no private IP" }} ·
|
||||||
|
{{ server.status.replace("-", " ") }}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
v-if="network.servers.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No servers attached.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card v-if="networks.length === 0" class="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No private networks</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Networks are created when the first server is provisioned for a provider zone.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
||||||
import { useCycleList, useInterval } from "@vueuse/core";
|
import { useCycleList, useInterval } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
@@ -12,18 +15,17 @@ import {
|
|||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { ref, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
server: {
|
server: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedStep = ref(null);
|
|
||||||
|
|
||||||
const { state: provisionMessage, next } = useCycleList([
|
const { state: provisionMessage, next } = useCycleList([
|
||||||
"Provisioning your server...",
|
"Provisioning your server...",
|
||||||
"Updating dependencies...",
|
"Updating dependencies...",
|
||||||
@@ -32,11 +34,73 @@ const { state: provisionMessage, next } = useCycleList([
|
|||||||
"Configuring ssh...",
|
"Configuring ssh...",
|
||||||
"Installing docker...",
|
"Installing docker...",
|
||||||
]);
|
]);
|
||||||
const { counter, reset, pause, resume } = useInterval(5000, { controls: true });
|
const { counter } = useInterval(5000, { controls: true });
|
||||||
|
|
||||||
watch(counter, () => {
|
watch(counter, () => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeProvisionOperation = computed(() =>
|
||||||
|
props.server.operations?.find((operation) => operation.kind === "server_provision"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firewallForm = useForm({
|
||||||
|
type: "allow",
|
||||||
|
ports: "",
|
||||||
|
from: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const addFirewallRule = (): void => {
|
||||||
|
firewallForm.post(
|
||||||
|
route("servers.firewall-rules.store", {
|
||||||
|
organisation: props.server.organisation_id,
|
||||||
|
server: props.server.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => firewallForm.reset("ports", "from"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyFirewallRule = (rule: Record<string, any>): void => {
|
||||||
|
if (!window.confirm(`Remove ${rule.type} ${rule.ports}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("servers.firewall-rules.destroy", {
|
||||||
|
organisation: props.server.organisation_id,
|
||||||
|
server: props.server.id,
|
||||||
|
firewallRule: rule.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyServer = (): void => {
|
||||||
|
if (!window.confirm(`Delete ${props.server.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
route("servers.destroy", {
|
||||||
|
organisation: props.server.organisation_id,
|
||||||
|
server: props.server.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const healServer = (): void => {
|
||||||
|
router.post(
|
||||||
|
route("servers.heal", {
|
||||||
|
organisation: props.server.organisation_id,
|
||||||
|
server: props.server.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,6 +134,7 @@ watch(counter, () => {
|
|||||||
<div class="leading-none opacity-40">
|
<div class="leading-none opacity-40">
|
||||||
{{ server.ipv4 }} • {{ server.ipv6 }}
|
{{ server.ipv4 }} • {{ server.ipv6 }}
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="destructive" @click="destroyServer">Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="server.status === 'active'">
|
<template v-if="server.status === 'active'">
|
||||||
@@ -139,58 +204,99 @@ watch(counter, () => {
|
|||||||
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
|
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-4">
|
<CardContent class="py-4">
|
||||||
|
<OperationTimeline :operations="server.service_operations" show-target />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Firewall</CardTitle>
|
||||||
|
<CardDescription>Rules Keystone knows about for this server.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-4">
|
||||||
|
<form class="grid gap-3 rounded-md border p-3" @submit.prevent="addFirewallRule">
|
||||||
|
<div class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="firewall_type">Action</Label>
|
||||||
|
<select
|
||||||
|
id="firewall_type"
|
||||||
|
v-model="firewallForm.type"
|
||||||
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="firewallForm.errors.type" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="firewall_ports">Ports</Label>
|
||||||
|
<Input
|
||||||
|
id="firewall_ports"
|
||||||
|
v-model="firewallForm.ports"
|
||||||
|
placeholder="80/tcp"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="firewallForm.errors.ports" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="firewall_from">Source</Label>
|
||||||
|
<Input
|
||||||
|
id="firewall_from"
|
||||||
|
v-model="firewallForm.from"
|
||||||
|
placeholder="any or CIDR"
|
||||||
|
/>
|
||||||
|
<InputError :message="firewallForm.errors.from" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" :disabled="firewallForm.processing">
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<div
|
<div
|
||||||
v-for="operation in server.service_operations"
|
v-for="rule in server.firewall_rules"
|
||||||
:key="operation.id"
|
:key="rule.id"
|
||||||
class="flex gap-4"
|
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div class="w-48 leading-none">{{ operation.target.name }}</div>
|
<div>
|
||||||
<div class="w-full space-y-4">
|
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div>
|
||||||
<div
|
<div class="text-muted-foreground">
|
||||||
v-for="step in operation.steps"
|
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
|
||||||
:key="step.id"
|
{{ rule.status }}
|
||||||
class="flex items-center space-y-1"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="text-sm font-semibold leading-none">
|
|
||||||
{{ step.name ?? "Unnamed Step" }}
|
|
||||||
</div>
|
|
||||||
<div v-if="step.error_logs">
|
|
||||||
<pre class="text-xs text-muted-foreground"
|
|
||||||
>{{
|
|
||||||
step.error_logs_excerpt.length !==
|
|
||||||
step.error_logs
|
|
||||||
? "... "
|
|
||||||
: ""
|
|
||||||
}}{{ step.error_logs_excerpt }}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="step.logs">
|
|
||||||
<pre class="text-xs text-muted-foreground"
|
|
||||||
>{{
|
|
||||||
step.logs_excerpt.length !== step.logs
|
|
||||||
? "... "
|
|
||||||
: ""
|
|
||||||
}}{{ step.logs_excerpt }}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="link"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
selectedStep = step;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="`Remove ${rule.type} ${rule.ports}`"
|
||||||
|
@click="destroyFirewallRule(rule)"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="size-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="server.firewall_rules.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No firewall rules recorded.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Private network</CardTitle>
|
||||||
|
<CardDescription>Provider network membership.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="text-sm">
|
||||||
|
<div v-if="server.network">
|
||||||
|
<div class="font-medium">{{ server.network.name }}</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
{{ server.network.ip_range }} · {{ server.network.network_zone }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground">No private network attached.</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +307,12 @@ watch(counter, () => {
|
|||||||
<LoaderCircleIcon class="size-8 animate-spin" />
|
<LoaderCircleIcon class="size-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-grow">
|
<div class="relative flex-grow">
|
||||||
|
<OperationTimeline
|
||||||
|
v-if="activeProvisionOperation"
|
||||||
|
:operations="[activeProvisionOperation]"
|
||||||
|
/>
|
||||||
<Transition
|
<Transition
|
||||||
|
v-else
|
||||||
enter-active-class="transition duration-500 ease-in-out"
|
enter-active-class="transition duration-500 ease-in-out"
|
||||||
enter-from-class="opacity-0 -translate-x-4"
|
enter-from-class="opacity-0 -translate-x-4"
|
||||||
enter-to-class="opacity-100 translate-x-0"
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
@@ -213,37 +324,57 @@ watch(counter, () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button size="xs" disabled title="Services can be added after provisioning completes.">
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
Add service
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Server unavailable</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Service changes are disabled while this server is
|
||||||
|
{{ server.status }}.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="server.status === 'provisioning-failed'"
|
||||||
|
variant="secondary"
|
||||||
|
@click="healServer"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon class="size-4" />
|
||||||
|
Queue heal check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent v-if="server.operations?.length" class="pt-0">
|
||||||
|
<OperationTimeline :operations="server.operations" />
|
||||||
|
</CardContent>
|
||||||
|
<CardContent v-else-if="server.status === 'provisioning-failed'" class="pt-0">
|
||||||
|
<CardDescription>
|
||||||
|
Keystone no longer has the provider root password. The heal check uses
|
||||||
|
the managed SSH user and records the checks as a server operation.
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
<template> Something else </template>
|
|
||||||
|
|
||||||
<div v-if="$page.props.flash?.server_credentials" class="p-5">
|
<div v-if="$page.props.flash?.server_credentials" class="p-5">
|
||||||
<div class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
WILL NOT BE SHOWN AGAIN:
|
WILL NOT BE SHOWN AGAIN:
|
||||||
{{ $page.props.flash.server_credentials }}
|
{{ $page.props.flash.server_credentials }}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Keystone uses its managed SSH key for subsequent operations. This password is
|
||||||
|
informational for initial access only.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
:open="!!selectedStep"
|
|
||||||
@update:open="($event) => (!$event ? (selectedStep = null) : null)"
|
|
||||||
>
|
|
||||||
<DialogContent class="md:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Logs for {{ selectedStep?.name }}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<section v-if="selectedStep?.logs">
|
|
||||||
<h3 class="text-sm font-medium">Logs</h3>
|
|
||||||
<pre class="text-xs text-muted-foreground">{{ selectedStep?.logs }}</pre>
|
|
||||||
</section>
|
|
||||||
<section v-if="selectedStep?.error_logs">
|
|
||||||
<h3 class="text-sm font-medium">Error Logs</h3>
|
|
||||||
<pre class="max-w-full overflow-x-scroll text-xs text-muted-foreground">{{
|
|
||||||
selectedStep?.error_logs
|
|
||||||
}}</pre>
|
|
||||||
</section>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- {{ server }} -->
|
<!-- {{ server }} -->
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user