wowowowowo
Some checks failed
CI / Lint (push) Failing after 22s
CI / Tests (push) Failing after 33s

This commit is contained in:
2026-05-28 15:15:41 +01:00
parent 8f603122e2
commit 5b977c1f41
129 changed files with 9943 additions and 722 deletions

View File

@@ -63,3 +63,5 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
HETZNER_KEY=

View File

@@ -8,6 +8,7 @@ use App\Actions\Applications\VerifyRepositoryAccess;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Http\Requests\StoreApplicationRequest;
use App\Http\Requests\UpdateApplicationRequest;
use App\Models\Application;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
@@ -27,9 +28,12 @@ class ApplicationController extends Controller
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
@@ -38,8 +42,9 @@ class ApplicationController extends Controller
$application = $organisation->applications()->create([
'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_type' => RepositoryType::GIT,
'repository_type' => $request->enum('repository_type', RepositoryType::class),
'default_branch' => $request->string('default_branch')->toString(),
]);
@@ -56,14 +61,24 @@ class ApplicationController extends Controller
$id = $request->route('application');
$organisation = Organisation::findOrFail($request->route('organisation'));
$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.attachments.service',
'environments.variables',
'organisation',
'sourceProvider',
])->whereBelongsTo($organisation)->findOrFail($id);
return inertia('applications/Show', [
'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) {
return $application
->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
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -86,4 +146,23 @@ class ApplicationController extends Controller
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;
}
}

View 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,
]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
use App\Http\Requests\UpdateEnvironmentAttachmentRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -27,6 +28,12 @@ class EnvironmentAttachmentController extends Controller
->orderBy('name')
->get(['id', 'name', 'type', 'category']),
'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'));
$service = $organisation->services()->findOrFail($request->integer('service_id'));
app(AttachManagedService::class)->execute(
$attachment = app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: $request->enum('role', EnvironmentAttachmentRole::class),
@@ -46,6 +53,17 @@ class EnvironmentAttachmentController extends Controller
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()
->route('environments.show', [
'organisation' => $organisation->id,
@@ -54,4 +72,73 @@ class EnvironmentAttachmentController extends Controller
])
->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.');
}
}

View File

@@ -2,12 +2,54 @@
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\Support\CaddyRouteRenderer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Response;
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
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -15,18 +57,151 @@ class EnvironmentController extends Controller
$environment = $application->environments()
->with([
'services.replicas',
'services.endpoints',
'services.slices',
'services.operations.steps',
'attachments.service',
'attachments.serviceSlice',
'variables',
'buildArtifacts.builtByService',
'operations.steps',
'operations.children.target',
])
->findOrFail($request->route('environment'));
$serverCount = $this->serverIdsFor($environment)->count();
return inertia('environments/Show', [
'application' => $application,
'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();
}
}

View File

@@ -2,15 +2,17 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreEnvironmentDeploymentRequest;
use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection;
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(
(int) $application->organisation_id === (int) $organisation->id
@@ -18,7 +20,16 @@ class EnvironmentDeploymentController extends Controller
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', [
'organisation' => $organisation->id,
@@ -26,4 +37,19 @@ class EnvironmentDeploymentController extends Controller
'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();
}
}

View 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,
]);
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Http\Controllers;
use App\Enums\EnvironmentVariableSource;
use App\Http\Requests\ImportEnvironmentVariablesRequest;
use App\Http\Requests\StoreEnvironmentVariableRequest;
use App\Http\Requests\UpdateEnvironmentVariableRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -11,6 +13,22 @@ use Inertia\Response;
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
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -35,11 +53,136 @@ class EnvironmentVariableController extends Controller
'value' => $request->string('value')->toString(),
'source' => EnvironmentVariableSource::USER,
'service_slice_id' => null,
'overridable' => true,
'overridable' => $request->boolean('overridable', true),
]);
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.');
}
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;
}
}

View 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),
];
}
}

View File

@@ -11,6 +11,10 @@ class OnboardingController extends Controller
{
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
$applicationNeedingDeployKey = $organisation->applications()
->whereNull('deploy_key_installed_at')
->first();
$steps = [
[
'key' => 'organisation',
@@ -48,6 +52,17 @@ class OnboardingController extends Controller
'complete' => $organisation->applications_count > 0,
'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)];

View 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,
};
}
}

View File

@@ -2,8 +2,11 @@
namespace App\Http\Controllers;
use App\Enums\ServiceStatus;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Service;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -15,7 +18,23 @@ class OrganisationController extends Controller
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->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(),
],
]);
}
}

View 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.');
}
}

View 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.');
}
}

View File

@@ -4,13 +4,27 @@ namespace App\Http\Controllers;
use App\Enums\RegistryType;
use App\Http\Requests\StoreRegistryRequest;
use App\Http\Requests\UpdateRegistryRequest;
use App\Models\BuildArtifact;
use App\Models\Organisation;
use App\Models\Registry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
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
{
Organisation::findOrFail($request->route('organisation'));
@@ -38,4 +52,75 @@ class RegistryController extends Controller
->route('organisations.show', ['organisation' => $organisation->id])
->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.');
}
}

View File

@@ -3,26 +3,33 @@
namespace App\Http\Controllers;
use App\Actions\GenerateRandomSlug;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServerStatus;
use App\Jobs\Servers\WaitForServerToConnect;
use App\Models\Organisation;
use App\Models\Provider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Inertia\Response;
class ServerController extends Controller
{
public function index(Request $request)
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('servers/Index', [
'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'));
@@ -55,7 +62,7 @@ class ServerController extends Controller
]);
}
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$request->validate([
'provider' => ['required', 'exists:providers,id'],
@@ -135,13 +142,63 @@ class ServerController extends Controller
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'));
$server = $organisation->servers()->findOrFail($request->route('server'));
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.');
}
}

View 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.');
}
}

View File

@@ -3,10 +3,12 @@
namespace App\Http\Controllers;
use App\Actions\Services\CreateService;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceRequest;
use App\Http\Requests\UpdateServiceRequest;
use App\Models\Organisation;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -49,7 +51,7 @@ class ServiceController extends Controller
{
$server = Server::findOrFail($request->route('server'));
$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'));
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
{
$server = Server::findOrFail($request->route('server'));
@@ -66,6 +85,7 @@ class ServiceController extends Controller
return inertia('services/Edit', [
'server' => $server,
'service' => $service,
'deployPolicies' => array_values(DeployPolicy::toArray()),
]);
}
@@ -74,7 +94,31 @@ class ServiceController extends Controller
$server = Server::findOrFail($request->route('server'));
$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()
->route('services.show', [
@@ -84,4 +128,19 @@ class ServiceController extends Controller
])
->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.');
}
}

View 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,
]);
}
}

View 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.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
use App\Actions\Services\ResolveServiceImageDigest;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceUpdateRequest;
use App\Models\Organisation;
@@ -33,6 +34,7 @@ class ServiceUpdateController extends Controller
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
): RedirectResponse {
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(
service: $service,
@@ -45,4 +47,26 @@ class ServiceUpdateController extends Controller
'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.');
}
}

View File

@@ -4,13 +4,26 @@ namespace App\Http\Controllers;
use App\Enums\SourceProviderType;
use App\Http\Requests\StoreSourceProviderRequest;
use App\Http\Requests\UpdateSourceProviderRequest;
use App\Models\Organisation;
use App\Models\SourceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
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
{
Organisation::findOrFail($request->route('organisation'));
@@ -35,4 +48,44 @@ class SourceProviderController extends Controller
->route('organisations.show', ['organisation' => $organisation->id])
->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.');
}
}

View File

@@ -31,7 +31,9 @@ class HandleInertiaRequests extends Middleware
...parent::share($request),
'name' => config('app.name'),
'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,
'application' => $request->route('application')
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))

View 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'],
];
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\RepositoryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreApplicationRequest extends FormRequest
{
@@ -23,6 +25,8 @@ class StoreApplicationRequest extends FormRequest
{
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._\/-]+$/'],
'environment_name' => ['required', 'string', 'max:255'],

View File

@@ -29,6 +29,9 @@ class StoreEnvironmentAttachmentRequest extends FormRequest
'name' => ['nullable', 'string', 'max:255'],
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'is_primary' => ['boolean'],
'domain' => ['nullable', 'string', 'max:255'],
'path_prefix' => ['nullable', 'string', 'max:255'],
'tls_enabled' => ['boolean'],
];
}
}

View 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}$/'],
];
}
}

View 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'],
];
}
}

View File

@@ -24,6 +24,7 @@ class StoreEnvironmentVariableRequest extends FormRequest
return [
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'value' => ['nullable', 'string'],
'overridable' => ['boolean'],
];
}
}

View 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'],
];
}
}

View 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)],
];
}
}

View 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'],
];
}
}

View 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:.\/-]+$/'],
];
}
}

View 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'],
];
}
}

View File

@@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest
return [
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
'backup_requested' => ['sometimes', 'boolean'],
'confirmation' => ['required', 'string'],
];
}
}

View 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._\/-]+$/'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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)],
];
}
}

View 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)],
];
}
}

View 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'],
];
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\DeployPolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateServiceRequest extends FormRequest
{
@@ -26,6 +28,16 @@ class UpdateServiceRequest extends FormRequest
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
'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'],
];
}
}

View 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'],
];
}
}

View File

@@ -18,6 +18,7 @@ use App\Models\Operation;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Services\Compose\ComposeRenderer;
use App\Support\CaddyRouteRenderer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use InvalidArgumentException;
@@ -29,6 +30,7 @@ class DeployEnvironment implements ShouldQueue
public function __construct(
public Environment $environment,
public ?string $targetCommit = null,
) {
//
}
@@ -51,7 +53,7 @@ class DeployEnvironment implements ShouldQueue
'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);
if ($services === []) {
@@ -378,15 +380,25 @@ class DeployEnvironment implements ShouldQueue
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
{
$containerName = $attachment->service->replicas()->first()?->container_name;
$config = $attachment->serviceSlice?->config ?? [];
$domain = $config['domain'] ?? null;
$tlsEnabled = $config['tls_enabled'] ?? true;
$reloadCommand = $containerName
? '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";
$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 [
[
'name' => 'Validate Caddy route configuration',
'script' => 'test -s /home/keystone/gateway/Caddyfile',
],
[
'name' => 'Check TLS certificate status',
'script' => $certificateCheck,
],
[
'name' => 'Reload Caddy',
'script' => $reloadCommand,
@@ -406,15 +418,13 @@ class DeployEnvironment implements ShouldQueue
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
{
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
$upstreams = $this->gatewayUpstreams($attachment);
$caddyfile = app(CaddyRouteRenderer::class)->render($attachment, $upstreams);
return implode("\n", [
'mkdir -p /home/keystone/gateway/Caddyfile.d',
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
"{$route} {",
' reverse_proxy '.implode(' ', $upstreams),
'}',
$caddyfile,
'KEYSTONE_CADDY_ROUTE',
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
]);

View File

@@ -29,6 +29,11 @@ class Application extends Model
return $this->belongsTo(Organisation::class);
}
public function sourceProvider(): BelongsTo
{
return $this->belongsTo(SourceProvider::class);
}
public function environments(): HasMany
{
return $this->hasMany(Environment::class);

View File

@@ -30,6 +30,11 @@ class Organisation extends Model
->withTimestamps();
}
public function invitations(): HasMany
{
return $this->hasMany(OrganisationInvitation::class);
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);

View 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');
}
}

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Spatie\Ssh\Ssh;
class Server extends Model
@@ -69,6 +70,11 @@ class Server extends Model
)->where('target_type', (new Service)->getMorphClass());
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
public function sshClient(string $user = 'root'): Ssh
{
return Ssh::create($user, $this->ipv4)

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\SourceProviderType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SourceProvider extends Model
{
@@ -22,4 +23,9 @@ class SourceProvider extends Model
{
return $this->belongsTo(Organisation::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class);
}
}

View 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),
' }',
'}',
]);
}
}

BIN
bun.lockb

Binary file not shown.

2
composer.lock generated
View File

@@ -9819,5 +9819,5 @@
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View 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),
];
}
}

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -65,7 +65,6 @@ class DatabaseSeeder extends Seeder
$application->environments()->create([
'name' => 'Dev',
'branch' => 'main',
'url' => 'https://dev.clipbin.hjb.dev',
'status' => 'active',
]);
}

296
docs/managed-registry.md Normal file
View 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.

View File

@@ -15,14 +15,14 @@ Conventions:
## 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.
- **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 — 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.
- **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 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)
- **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.
- **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`.
@@ -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.
- **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 — 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.
## 5. Applications & Environments (Spec §2, §6, §17)
@@ -107,7 +107,7 @@ Conventions:
## 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.
- **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`.
@@ -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 — 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 — 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)
@@ -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.
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.
---
## 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. |

View File

@@ -1,8 +1,10 @@
{
"tools": {
"$schema": "https://opencode.ai/config.json",
"mcp": {
"laravel-boost": {
"command": "php",
"args": ["artisan", "boost:mcp"]
"type": "local",
"command": ["php", "artisan", "boost:mcp"],
"enabled": true
}
}
}

View File

@@ -22,7 +22,16 @@ import UserMenuContent from "@/components/UserMenuContent.vue";
import { getInitials } from "@/composables/useInitials";
import type { BreadcrumbItem, NavItem } from "@/types";
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";
interface Props {
@@ -36,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage();
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(
() => (url: string) =>
@@ -54,20 +63,31 @@ const mainNavItems: NavItem[] = [
];
if (page.props.organisation) {
const organisationId = page.props?.organisation?.id;
mainNavItems.push({
title: page.props.organisation.name,
href: new URL(
route("organisations.show", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
icon: BoltIcon,
});
mainNavItems.push({
title: "Environments",
href: new URL(
route("environments.index", {
organisation: organisationId,
}),
).pathname,
icon: BoxesIcon,
});
mainNavItems.push({
title: "Applications",
href: new URL(
route("applications.index", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
icon: AppWindowIcon,
@@ -76,11 +96,38 @@ if (page.props.organisation) {
title: "Servers",
href: new URL(
route("servers.index", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
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[] = [

View File

@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { type NavItem } from "@/types";
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";
const mainNavItems: NavItem[] = [
@@ -27,6 +27,20 @@ const mainNavItems: NavItem[] = [
const organisation = usePage().props.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({
title: "Servers",
href: route("servers.index", {
@@ -34,6 +48,29 @@ if (organisation) {
}),
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[] = [];

View File

@@ -50,6 +50,7 @@ const environment = usePage().props.environment ?? null;
<DropdownMenuContent>
<DropdownMenuItem
v-for="org in $page.props.auth.user?.organisations"
:key="org.id"
:as="Link"
:href="route('organisations.show', { organisation: org.id })"
>{{ org.name }}</DropdownMenuItem
@@ -86,6 +87,7 @@ const environment = usePage().props.environment ?? null;
<DropdownMenuContent>
<DropdownMenuItem
v-for="app in organisation?.applications"
:key="app.id"
:as="Link"
:href="
route('applications.show', {
@@ -128,6 +130,7 @@ const environment = usePage().props.environment ?? null;
<DropdownMenuContent>
<DropdownMenuItem
v-for="env in application?.environments"
:key="env.id"
:as="Link"
:href="
route('environments.show', {

View File

@@ -1,15 +1,18 @@
<script setup>
defineProps({
modelValue: String,
disabled: Boolean,
value: String,
name: String,
});
<script setup lang="ts">
defineProps<{
modelValue?: string | number | null;
disabled?: boolean;
value: string | number;
name: string;
describedBy?: string;
}>();
const emit = defineEmits(["update:modelValue"]);
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
function onChange(event) {
emit("update:modelValue", event.target.value);
function onChange(event: Event): void {
emit("update:modelValue", (event.target as HTMLInputElement).value);
}
</script>
@@ -23,7 +26,8 @@ function onChange(event) {
:value="value"
class="invisible absolute inset-0"
:disabled="disabled"
:checked="modelValue === value"
:checked="String(modelValue) === String(value)"
:aria-describedby="describedBy"
@change="onChange"
/>
<slot />

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import {
Dialog,
DialogContent,
@@ -13,23 +13,16 @@ import { LoaderCircleIcon } from "lucide-vue-next";
import { ref, watch } from "vue";
import { Card } from "./ui/card";
const props = defineProps({
servers: {
type: Array,
required: false,
},
serviceCategory: {
type: String,
required: false,
validate: (value) => {
return Object.keys(ServiceCategory).includes(value);
},
},
});
const props = defineProps<{
servers?: Record<string, any>[];
serviceCategory?: keyof typeof ServiceCategory;
}>();
const isOpen = ref(false);
defineEmits(["select"]);
defineEmits<{
select: [server: Record<string, any>];
}>();
watch(isOpen, () => {
if (isOpen.value && props.servers === undefined) {

View File

@@ -1,27 +1,20 @@
<script setup>
<script setup lang="ts">
import { Card } from "@/components/ui/card";
import ServiceCategory from "@/enums/ServiceCategory";
import ServiceStatus from "@/enums/ServiceStatus";
import ServiceType from "@/enums/ServiceType";
import { DoorOpenIcon } from "lucide-vue-next";
defineProps({
icon: {
type: [Object, Function],
default: () => DoorOpenIcon,
},
serviceType: {
type: String,
default: ServiceType.GATEWAY,
},
serviceCategory: {
type: String,
default: ServiceCategory.DATABASE,
},
status: {
type: String,
default: ServiceStatus.UNKNOWN,
},
withDefaults(defineProps<{
icon?: object | Function;
serviceType?: string;
serviceCategory?: string;
status?: string;
}>(), {
icon: () => DoorOpenIcon,
serviceType: ServiceType.GATEWAY,
serviceCategory: ServiceCategory.DATABASE,
status: ServiceStatus.UNKNOWN,
});
</script>
<template>
@@ -39,21 +32,21 @@ defineProps({
<span
class="inline-block size-1 rounded-full dark:bg-zinc-500"
:class="{
'bg-zinc-300 dark:bg-zinc-500':
'bg-zinc-500 dark:bg-zinc-400':
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
'bg-green-300 dark:bg-green-500': status === ServiceStatus.RUNNING,
'bg-red-300 dark:bg-red-500': status === ServiceStatus.STOPPED,
'bg-yellow-300 dark:bg-yellow-500': status === ServiceStatus.INSTALLING,
'bg-green-600 dark:bg-green-400': status === ServiceStatus.RUNNING,
'bg-red-600 dark:bg-red-400': status === ServiceStatus.STOPPED,
'bg-yellow-600 dark:bg-yellow-400': status === ServiceStatus.INSTALLING,
}"
></span>
<span
class="text-xs dark:text-zinc-500"
class="text-xs dark:text-zinc-400"
:class="{
'text-zinc-300 dark:text-zinc-500':
'text-zinc-600 dark:text-zinc-400':
status === ServiceStatus.UNKNOWN || status === ServiceStatus.NOT_INSTALLED,
'text-green-300 dark:text-green-500': status === ServiceStatus.RUNNING,
'text-red-300 dark:text-red-500': status === ServiceStatus.STOPPED,
'text-yellow-300 dark:text-yellow-500': status === ServiceStatus.INSTALLING,
'text-green-700 dark:text-green-400': status === ServiceStatus.RUNNING,
'text-red-700 dark:text-red-400': status === ServiceStatus.STOPPED,
'text-yellow-700 dark:text-yellow-400': status === ServiceStatus.INSTALLING,
}"
>{{ status.replaceAll("-", " ") }}</span
>

View 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>

View File

@@ -5,12 +5,11 @@ import { type BreadcrumbItem } from "@/types";
import { Head, Link } from "@inertiajs/vue3";
import { ChevronRightIcon } from "lucide-vue-next";
defineProps({
organisations: {
type: Array,
required: true,
},
});
defineProps<{
organisations: Record<string, any>[];
recentOperations: Record<string, any>[];
unhealthyServices: Record<string, any>[];
}>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -24,23 +23,80 @@ const breadcrumbs: BreadcrumbItem[] = [
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col items-center gap-4 rounded-xl p-4">
<Card class="w-80">
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader class="border-b-muted-background border-b">
<CardTitle>Your Organisation</CardTitle>
<CardDescription> Select an organisation to view its details. </CardDescription>
<CardTitle>Organisations</CardTitle>
<CardDescription>Select an organisation to view its environments.</CardDescription>
</CardHeader>
<CardContent class="divide-y-muted-foreground divide-y p-0">
<Link
v-for="organisation in organisations"
:key="organisation.id"
:href="route('organisations.show', { organisation: organisation.id })"
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" />
</Link>
</CardContent>
</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>
</AppLayout>
</template>

View File

@@ -1,13 +1,21 @@
<script setup>
<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, useForm } from "@inertiajs/vue3";
import { Head, Link, useForm } from "@inertiajs/vue3";
defineProps<{
sourceProviders: Record<string, any>[];
repositoryTypes: Record<string, string>;
}>();
const form = useForm({
name: "",
source_provider_id: "",
repository_type: "git",
repository_url: "",
default_branch: "main",
environment_name: "production",
@@ -31,7 +39,7 @@ const form = useForm({
]"
>
<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="
form.post(
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>
</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">
<Label for="name">Name</Label>
<Input

View 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>

View File

@@ -1,15 +1,13 @@
<script setup>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PlusIcon } from "lucide-vue-next";
const props = defineProps({
applications: {
type: [Object, null],
required: true,
},
});
defineProps<{
applications: Record<string, any>[];
}>();
</script>
<template>
@@ -26,7 +24,12 @@ const props = defineProps({
]"
>
<div class="flex items-center justify-between gap-3 p-4">
<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>
<Button
:as="Link"
@@ -36,6 +39,7 @@ const props = defineProps({
})
"
>
<PlusIcon class="size-4" />
Create
</Button>
</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">
<Card
v-for="application in applications"
:key="`application{$applications.id}`"
:key="application.id"
class="relative w-full"
>
<CardHeader>
@@ -62,6 +66,28 @@ const props = defineProps({
class="absolute inset-0"
></Link>
</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>
</AppLayout>
</template>

View File

@@ -2,22 +2,29 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { Head, Link, router } from "@inertiajs/vue3";
import {
BoxesIcon,
ChevronDownIcon,
ExternalLinkIcon,
GitBranchIcon,
KeyRoundIcon,
PencilIcon,
PlusIcon,
RocketIcon,
} from "lucide-vue-next";
const props = defineProps({
application: {
type: Object,
required: true,
},
});
defineProps<{
application: Record<string, any>;
deploymentRequirements: Record<string, any>;
}>();
</script>
<template>
@@ -30,34 +37,85 @@ const props = defineProps({
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: props.application.name,
title: application.name,
href: route('applications.show', {
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 items-center gap-3">
<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>
<Card v-if="application.deploy_key_public && !application.deploy_key_installed_at">
<Card v-if="application.deploy_key_public">
<CardHeader>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-3">
<div class="flex items-center gap-2">
<KeyRoundIcon class="size-4" />
<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>
<pre
class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs"
>{{ application.deploy_key_public }}</pre
>
</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
class="shrink-0"
@click="
router.post(
route('applications.verify-repository', {
@@ -68,7 +126,33 @@ const props = defineProps({
"
>
<GitBranchIcon class="size-4" />
Verify
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
:as="Link"
:href="
route('registries.create', {
organisation: $page.props.organisation.id,
})
"
>
Configure registry
</Button>
</div>
</CardHeader>
@@ -77,6 +161,19 @@ const props = defineProps({
<div>
<div class="mb-3 flex items-center justify-between">
<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 class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card
@@ -103,6 +200,38 @@ const props = defineProps({
Branch: {{ environment.branch }} &bull;
{{ environment.services?.length ?? 0 }} services
</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
v-if="environment.variables?.length"
class="mt-3 flex flex-wrap gap-2"
@@ -120,7 +249,7 @@ const props = defineProps({
</Badge>
</div>
</div>
<div class="flex shrink-0 gap-2">
<div class="flex shrink-0 flex-wrap gap-2">
<Button
:as="Link"
size="xs"
@@ -138,6 +267,12 @@ const props = defineProps({
</Button>
<Button
size="xs"
:disabled="deploymentRequirements.registryRequired"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
@click="
router.post(
route('environment-deployments.store', {
@@ -151,9 +286,27 @@ const props = defineProps({
<RocketIcon class="size-4" />
Deploy
</Button>
<Button
size="xs"
variant="secondary"
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button size="xs" variant="secondary">
More
<ChevronDownIcon class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
:as="Link"
:href="
route('environments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Settings
</DropdownMenuItem>
<DropdownMenuItem
@click="
router.post(
route('environment-migrations.store', {
@@ -165,11 +318,9 @@ const props = defineProps({
"
>
Migrate
</Button>
<Button
</DropdownMenuItem>
<DropdownMenuItem
:as="Link"
size="xs"
variant="secondary"
:href="
route('environment-variables.create', {
organisation: $page.props.organisation.id,
@@ -178,11 +329,9 @@ const props = defineProps({
})
"
>
Env
</Button>
<Button
size="xs"
variant="secondary"
Variables
</DropdownMenuItem>
<DropdownMenuItem
@click="
router.post(
route('environment-workers.store', {
@@ -193,11 +342,10 @@ const props = defineProps({
)
"
>
Worker
</Button>
<Button
Add worker
</DropdownMenuItem>
<DropdownMenuItem
:as="Link"
size="xs"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id,
@@ -206,8 +354,25 @@ const props = defineProps({
})
"
>
Attach
</Button>
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>
</CardHeader>

View 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>

View 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>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -7,24 +7,13 @@ import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
services: {
type: Array,
required: true,
},
roles: {
type: Array,
required: true,
},
});
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
services: Record<string, any>[];
roles: string[];
compatibility: Record<string, string[]>;
}>();
const form = useForm({
service_id: props.services[0]?.id ?? null,
@@ -32,18 +21,16 @@ const form = useForm({
name: "",
env_prefix: "",
is_primary: true,
domain: "",
path_prefix: "/",
tls_enabled: true,
});
const compatibleServices = computed(() => {
const roleTypes = {
database: ["postgres"],
cache: ["valkey"],
queue: ["valkey"],
gateway: ["caddy"],
};
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";
});
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(
compatibleServices,
(services) => {
@@ -142,6 +157,19 @@ watch(
</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">
<Label for="role">Role</Label>
<select
@@ -180,9 +208,31 @@ watch(
</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">
<input v-model="form.is_primary" type="checkbox" class="size-4" />
<span>
Primary attachment
<span class="block text-muted-foreground">
Primary attachments provide the default unprefixed variables for this role.
</span>
</span>
</label>
<div class="flex items-center justify-end">

View 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>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -6,20 +6,15 @@ import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
});
defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
}>();
const form = useForm({
key: "",
value: "",
overridable: true,
});
</script>
@@ -74,6 +69,22 @@ const form = useForm({
<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" />
<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">
<Button type="submit" :disabled="form.processing">Save</Button>
</div>

View 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>

View 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&#10;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>

View 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>

View 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>

View 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>

View File

@@ -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 { 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, Link, router } from "@inertiajs/vue3";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import {
DatabaseIcon,
GitBranchIcon,
ListChecksIcon,
PencilIcon,
PlusIcon,
RocketIcon,
ServerIcon,
SettingsIcon,
} from "lucide-vue-next";
import { computed } from "vue";
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
deploymentRequirements: {
registryRequired: boolean;
registryCount: number;
serverCount: number;
};
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>
<template>
@@ -58,21 +96,34 @@ const props = defineProps({
<p class="mt-1 text-sm text-muted-foreground">
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
</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 class="flex flex-wrap gap-2">
<Button
@click="
router.post(
route('environment-deployments.store', {
:as="Link"
variant="secondary"
:href="
route('environments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
})
"
>
<RocketIcon class="size-4" />
Deploy
<PencilIcon class="size-4" />
Settings
</Button>
<Button
variant="secondary"
@@ -106,6 +157,66 @@ const props = defineProps({
</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="space-y-4">
<Card>
@@ -136,14 +247,14 @@ const props = defineProps({
</p>
</div>
<Button
v-if="service.server_id"
:as="Link"
size="sm"
variant="secondary"
:href="
route('services.show', {
route('environment-services.show', {
organisation: $page.props.organisation.id,
server: service.server_id,
application: environment.application_id,
environment: environment.id,
service: service.id,
})
"
@@ -160,21 +271,59 @@ const props = defineProps({
<CardHeader>
<CardTitle>Operations</CardTitle>
</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">
<div
v-for="operation in environment.operations"
:key="operation.id"
class="flex items-center justify-between rounded-md border p-3"
v-for="artifact in environment.build_artifacts"
:key="artifact.id"
class="rounded-md border p-3 text-sm"
>
<span class="font-medium">{{
operation.kind.replace("_", " ")
}}</span>
<Badge
:variant="
operation.status === 'completed' ? 'success' : 'secondary'
"
>{{ operation.status.replace("_", " ") }}</Badge
<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" }}
<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>
</CardContent>
</Card>
@@ -193,16 +342,131 @@ const props = defineProps({
>
<div class="flex items-center gap-2 font-medium">
<DatabaseIcon class="size-4" />
<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>
<p class="mt-1 text-muted-foreground">
{{ attachment.service?.name }} ·
{{ attachment.service_slice?.name ?? "service level" }}
</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>
</CardContent>
</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>
<CardHeader>
<CardTitle>Variables</CardTitle>
@@ -221,7 +485,7 @@ const props = defineProps({
size="sm"
variant="secondary"
:href="
route('environment-variables.create', {
route('environment-variables.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
@@ -229,10 +493,41 @@ const props = defineProps({
"
>
<PlusIcon class="size-4" />
Add
Manage
</Button>
</CardContent>
</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>

View 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>

View 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>

View 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>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { CheckIcon, CircleIcon } from "lucide-vue-next";
defineProps({
organisation: { type: Object, required: true },
steps: { type: Array, required: true },
nextStep: { type: Object, required: true },
});
defineProps<{
organisation: Record<string, any>;
steps: Record<string, any>[];
nextStep: Record<string, any>;
}>();
</script>
<template>

View 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>

View 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>

View 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>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts">
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 AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, WhenVisible } from "@inertiajs/vue3";
import { Head, Link, router, WhenVisible } from "@inertiajs/vue3";
import {
AppWindowIcon,
GitBranchIcon,
PencilIcon,
ServerIcon,
ShieldCheckIcon,
Trash2Icon,
UserIcon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
@@ -30,6 +32,10 @@ defineProps({
type: Array,
required: false,
},
health: {
type: Object,
required: true,
},
});
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>
<template>
@@ -51,7 +65,22 @@ watch(
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<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">
<TabsList>
<TabsTrigger value="dashboard"> Dashboard </TabsTrigger>
@@ -121,6 +150,63 @@ watch(
</CardContent>
</Card>
</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 value="settings">
<WhenVisible data="registries">
@@ -147,10 +233,48 @@ watch(
class="flex items-center gap-2 px-2 py-1"
>
<ShieldCheckIcon class="size-4 text-muted-foreground" />
<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">{{
registry.type
}}</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
v-if="!registries?.length"
@@ -185,10 +309,35 @@ watch(
class="flex items-center gap-2 px-2 py-1"
>
<GitBranchIcon class="size-4 text-muted-foreground" />
<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">{{
sourceProvider.type
}}</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
v-if="!sourceProviders?.length"
@@ -201,21 +350,53 @@ watch(
</WhenVisible>
<WhenVisible data="providers">
<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">
<div>
<h3 class="text-2xl font-bold tracking-tight">Server Providers</h3>
<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
class="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border"
>
<div
v-for="provider in providers"
:key="provider.id"
class="flex items-center gap-2 px-2 py-1"
>
{{ provider.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
provider.type
}}</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>
</WhenVisible>

View 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>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -6,12 +6,9 @@ import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
defineProps({
registryTypes: {
type: Array,
required: true,
},
});
defineProps<{
registryTypes: string[];
}>();
const form = useForm({
name: "",

View 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>

View 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>

View 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>

View File

@@ -1,16 +1,16 @@
<script setup>
<script setup lang="ts">
import RadioButton from "@/components/RadioButton.vue";
import { Button } from "@/components/ui/button";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
import { watch } from "vue";
const props = defineProps({
providers: Array,
locations: Array,
serverTypes: Array,
images: Array,
});
const props = defineProps<{
providers?: Record<string, any>[];
locations?: Record<string, any>[];
serverTypes?: Record<string, any>[];
images?: Record<string, any>[];
}>();
const form = useForm({
provider: null,
@@ -30,7 +30,7 @@ watch(
watch(
() => form.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;
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 flex-wrap gap-2">
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Cloud provider">
<RadioButton
v-for="provider in providers"
:key="provider.id"
v-model="form.provider"
:value="provider.id"
:disabled="provider.disabled"
@@ -93,9 +94,15 @@ function loadServerTypes() {
{{ provider.name }}
</RadioButton>
</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
v-for="location in locations"
:key="location.id"
v-model="form.location"
:value="location.id"
:disabled="location.disabled"
@@ -107,26 +114,36 @@ function loadServerTypes() {
<div
v-if="form.location"
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
role="radiogroup"
aria-label="Server type"
>
<RadioButton
v-for="serverType in serverTypes?.sort((a, b) => a.cores - b.cores) ?? []"
:key="serverType.id"
v-model="form.server_type"
:value="serverType.id"
:disabled="serverType.disabled"
name="server-type"
:described-by="`server-type-${serverType.id}-description`"
>
<h5 class="text-lg font-semibold uppercase tracking-tight">
{{ serverType.name }}
</h5>
<p class="text-sm opacity-60">
<p :id="`server-type-${serverType.id}-description`" class="text-sm opacity-60">
{{ serverType.cores }} cores &bull; {{ serverType.memory }} GB RAM &bull;
{{ serverType.disk }} GB disk
</p>
</RadioButton>
</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
v-for="image in images"
:key="image.id"
v-model="form.image"
:value="image.id"
:disabled="image.disabled"

View File

@@ -1,16 +1,15 @@
<script setup>
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PlusIcon } from "lucide-vue-next";
const props = defineProps({
servers: {
type: [Object, null],
required: true,
},
});
defineProps<{
servers: Record<string, any>;
networks: Record<string, any>[];
}>();
</script>
<template>
@@ -27,7 +26,12 @@ const props = defineProps({
]"
>
<div class="flex items-center justify-between gap-3 p-4">
<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>
<Button
:as="Link"
@@ -36,14 +40,16 @@ const props = defineProps({
organisation: $page.props.organisation.id,
})
"
>Create</Button
>
<PlusIcon class="size-4" />
Create
</Button>
</div>
</div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="server in servers.data"
:key="`server{$servers.id}`"
:key="server.id"
class="relative w-full"
>
<CardHeader>
@@ -65,6 +71,91 @@ const props = defineProps({
class="absolute inset-0"
></Link>
</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>
<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>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { Head, Link } from "@inertiajs/vue3";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import { useCycleList, useInterval } from "@vueuse/core";
import {
DatabaseIcon,
@@ -12,18 +15,17 @@ import {
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
import { computed, watch } from "vue";
defineProps({
const props = defineProps({
server: {
type: Object,
required: true,
},
});
const selectedStep = ref(null);
const { state: provisionMessage, next } = useCycleList([
"Provisioning your server...",
"Updating dependencies...",
@@ -32,11 +34,73 @@ const { state: provisionMessage, next } = useCycleList([
"Configuring ssh...",
"Installing docker...",
]);
const { counter, reset, pause, resume } = useInterval(5000, { controls: true });
const { counter } = useInterval(5000, { controls: true });
watch(counter, () => {
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>
<template>
@@ -70,6 +134,7 @@ watch(counter, () => {
<div class="leading-none opacity-40">
{{ server.ipv4 }} &bull; {{ server.ipv6 }}
</div>
<Button size="sm" variant="destructive" @click="destroyServer">Delete</Button>
</div>
<template v-if="server.status === 'active'">
@@ -139,58 +204,99 @@ watch(counter, () => {
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
<Card>
<CardContent class="py-4">
<div
v-for="operation in server.service_operations"
:key="operation.id"
class="flex gap-4"
>
<div class="w-48 leading-none">{{ operation.target.name }}</div>
<div class="w-full space-y-4">
<div
v-for="step in operation.steps"
:key="step.id"
class="flex items-center space-y-1"
>
<div class="flex-1">
<div class="text-sm font-semibold leading-none">
{{ step.name ?? "Unnamed Step" }}
<OperationTimeline :operations="server.service_operations" show-target />
</CardContent>
</Card>
</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 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 v-else-if="step.logs">
<pre class="text-xs text-muted-foreground"
>{{
step.logs_excerpt.length !== step.logs
? "... "
: ""
}}{{ step.logs_excerpt }}</pre
>
<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>
<div>
<Button
size="xs"
variant="link"
@click="
() => {
selectedStep = step;
}
"
>
View
<Button type="submit" :disabled="firewallForm.processing">
<PlusIcon class="size-4" />
Add
</Button>
</div>
</form>
<div
v-for="rule in server.firewall_rules"
:key="rule.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div>
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div>
<div class="text-muted-foreground">
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
{{ rule.status }}
</div>
</div>
<Button
size="iconxs"
variant="ghost"
:aria-label="`Remove ${rule.type} ${rule.ports}`"
@click="destroyFirewallRule(rule)"
>
<Trash2Icon class="size-3" />
</Button>
</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>
</Card>
</div>
@@ -201,7 +307,12 @@ watch(counter, () => {
<LoaderCircleIcon class="size-8 animate-spin" />
</div>
<div class="relative flex-grow">
<OperationTimeline
v-if="activeProvisionOperation"
:operations="[activeProvisionOperation]"
/>
<Transition
v-else
enter-active-class="transition duration-500 ease-in-out"
enter-from-class="opacity-0 -translate-x-4"
enter-to-class="opacity-100 translate-x-0"
@@ -213,37 +324,57 @@ watch(counter, () => {
</Transition>
</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> Something else </template>
<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">
WILL NOT BE SHOWN AGAIN:
{{ $page.props.flash.server_credentials }}
</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>
<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 }} -->
</div>
</AppLayout>

Some files were not shown because too many files have changed in this diff Show More