Restructure UX and seed a fully simulated organisation
Rework the dashboard, environment topology view, header navigation, and status rendering, and standardise selects on a shadcn-vue component. Replace the thin database seeder with a SimulatedEnvironmentSeeder that builds a fully wired, mostly-running organisation (ACTIVE server fleet, managed + GHCR registries, Gitea source provider, ClipBin app with production/staging environments, services, slices, endpoints, managed variables, build artifacts, and a completed/in-progress/failed operations history) so the new UI renders against realistic data.
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked the dashboard to lead with recent applications and a latest-deployments activity feed with relative timestamps, replacing the organisation-picker layout.
|
||||||
|
- Rebuilt the environment view as a Network / Compute / Resources topology with dense spec cards, moving the commit-SHA deploy form and raw Caddyfile previews behind disclosures.
|
||||||
|
- Added a header organisation switcher, reordered the primary nav, and removed the onboarding nav item.
|
||||||
|
- Shared a fallback organisation when no organisation is in the route so the header navigation and switcher render on the dashboard, and gave the dashboard a header with a "New application" action and actionable empty states.
|
||||||
|
- Introduced a shared `StatusIndicator` (coloured dot + label) and standardised status rendering across the dashboard and environment views.
|
||||||
|
|
||||||
|
- Added a shadcn-vue `Select` component (radix-vue based) and replaced every native HTML `<select>` across the app with it.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Reworked the database seeder to generate a fully wired, mostly-running organisation: an ACTIVE server fleet with a control/build node, managed and GHCR registries, a Gitea source provider, and a ClipBin application with production and staging environments (web + postgres + valkey + caddy services, slices, endpoints, managed variables), plus build artifacts and an operations history covering completed, in-progress, and failed states.
|
||||||
- Expanded the managed registry plan with HTTPS registry requirements, image naming, credential handling, health checks, and build-node safeguards.
|
- Expanded the managed registry plan with HTTPS registry requirements, image naming, credential handling, health checks, and build-node safeguards.
|
||||||
- Added managed registry build planning defaults, stable managed image references, and digest-based Compose rendering for registry-backed deployments.
|
- Added managed registry build planning defaults, stable managed image references, and digest-based Compose rendering for registry-backed deployments.
|
||||||
- Hardened managed registry planning so config-only registry URLs are not treated as ready registry records and pushed artifact digests come from Docker push output.
|
- Hardened managed registry planning so config-only registry URLs are not treated as ready registry records and pushed artifact digests come from Docker push output.
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'organisation' => $request->route('organisation')
|
'organisation' => $this->resolveOrganisation($request),
|
||||||
? Organisation::with('applications.environments')
|
|
||||||
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications'])
|
|
||||||
->findOrFail($this->routeKey($request->route('organisation')))
|
|
||||||
: null,
|
|
||||||
'application' => $request->route('application')
|
'application' => $request->route('application')
|
||||||
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
||||||
: null,
|
: null,
|
||||||
@@ -53,6 +49,20 @@ class HandleInertiaRequests extends Middleware
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveOrganisation(Request $request): ?Organisation
|
||||||
|
{
|
||||||
|
$query = Organisation::with('applications.environments')
|
||||||
|
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
||||||
|
|
||||||
|
if ($request->route('organisation')) {
|
||||||
|
return $query->findOrFail($this->routeKey($request->route('organisation')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$organisationId = $request->user()?->organisations()->value('organisations.id');
|
||||||
|
|
||||||
|
return $organisationId ? $query->find($organisationId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function routeKey(mixed $routeValue): mixed
|
private function routeKey(mixed $routeValue): mixed
|
||||||
{
|
{
|
||||||
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
|
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ namespace Database\Seeders;
|
|||||||
|
|
||||||
use App\Enums\OrganisationRole;
|
use App\Enums\OrganisationRole;
|
||||||
use App\Enums\ProviderType;
|
use App\Enums\ProviderType;
|
||||||
use App\Enums\RepositoryType;
|
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
@@ -36,36 +33,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
$provider = $organisation->providers()->create([
|
$provider = $organisation->providers()->create([
|
||||||
'name' => 'Hetzner',
|
'name' => 'Hetzner',
|
||||||
'type' => ProviderType::HETZNER,
|
'type' => ProviderType::HETZNER,
|
||||||
'token' => env('HETZNER_KEY'),
|
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->isProduction()) {
|
if (! app()->isProduction()) {
|
||||||
$network = $organisation->networks()->create([
|
app(SimulatedEnvironmentSeeder::class)->seed($organisation, $provider);
|
||||||
'name' => 'keystone',
|
|
||||||
'external_id' => 'net-12345',
|
|
||||||
'provider_id' => $provider->id,
|
|
||||||
'ip_range' => fake()->ipv4().'/24',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$servers = Server::factory(40)
|
|
||||||
->forNetwork($network->id)
|
|
||||||
->forOrganisation($organisation->id)
|
|
||||||
->forProvider($provider->id)
|
|
||||||
->create();
|
|
||||||
|
|
||||||
$organisation->servers()->saveMany($servers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$application = $organisation->applications()->create([
|
|
||||||
'name' => 'ClipBin',
|
|
||||||
'repository_url' => 'git@github.com:hjbdev/clipbin.git',
|
|
||||||
'repository_type' => RepositoryType::GIT,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$application->environments()->create([
|
|
||||||
'name' => 'Dev',
|
|
||||||
'branch' => 'main',
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
441
database/seeders/SimulatedEnvironmentSeeder.php
Normal file
441
database/seeders/SimulatedEnvironmentSeeder.php
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Actions\Applications\CreateLaravelEnvironment;
|
||||||
|
use App\Actions\Environments\AttachManagedService;
|
||||||
|
use App\Actions\Services\RegisterServiceEndpoint;
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\EnvironmentVariableSource;
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
|
use App\Enums\ServerStatus;
|
||||||
|
use App\Enums\ServiceCategory;
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Enums\SourceProviderType;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Network;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Provider;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SimulatedEnvironmentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private Registry $managedRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fully wired, mostly-running organisation: an ACTIVE server fleet,
|
||||||
|
* registries, a source provider, and one rich application with production +
|
||||||
|
* staging environments (web + postgres + valkey + caddy each), plus a
|
||||||
|
* believable operations history. Reuses the real domain actions so the graph
|
||||||
|
* is internally consistent, without dispatching any deployment jobs.
|
||||||
|
*/
|
||||||
|
public function seed(Organisation $organisation, Provider $provider): void
|
||||||
|
{
|
||||||
|
$network = $organisation->networks()->create([
|
||||||
|
'name' => 'keystone',
|
||||||
|
'external_id' => 'net-12345',
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'ip_range' => '10.0.0.0/24',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$control, $workers] = $this->seedFleet($organisation, $provider, $network);
|
||||||
|
|
||||||
|
$this->seedRegistries($organisation, $control);
|
||||||
|
$sourceProvider = $this->seedSourceProvider($organisation);
|
||||||
|
$application = $this->seedApplication($organisation, $sourceProvider);
|
||||||
|
|
||||||
|
$production = $this->seedEnvironment($application, $control, $workers, 'production', 'main');
|
||||||
|
$staging = $this->seedEnvironment($application, $control, $workers, 'staging', 'develop');
|
||||||
|
|
||||||
|
$this->seedVariety($production, $staging);
|
||||||
|
$this->seedOperationsHistory($control, $production, $staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Server, 1: Collection<int, Server>}
|
||||||
|
*/
|
||||||
|
private function seedFleet(Organisation $organisation, Provider $provider, Network $network): array
|
||||||
|
{
|
||||||
|
$factory = fn (): \Database\Factories\ServerFactory => Server::factory()
|
||||||
|
->forNetwork($network->id)
|
||||||
|
->forOrganisation($organisation->id)
|
||||||
|
->forProvider($provider->id);
|
||||||
|
|
||||||
|
$control = $factory()->create([
|
||||||
|
'name' => 'keystone-control-1',
|
||||||
|
'status' => ServerStatus::ACTIVE,
|
||||||
|
'provider_status' => 'running',
|
||||||
|
'private_ip' => '10.0.0.10',
|
||||||
|
'is_control_node' => true,
|
||||||
|
'build_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workers = collect(range(1, 3))->map(fn (int $index): Server => $factory()->create([
|
||||||
|
'name' => "keystone-worker-{$index}",
|
||||||
|
'status' => ServerStatus::ACTIVE,
|
||||||
|
'provider_status' => 'running',
|
||||||
|
'private_ip' => '10.0.0.'.(20 + $index),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return [$control, $workers];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedRegistries(Organisation $organisation, Server $control): void
|
||||||
|
{
|
||||||
|
$this->managedRegistry = $organisation->registries()->create([
|
||||||
|
'name' => 'Keystone Managed',
|
||||||
|
'type' => RegistryType::MANAGED,
|
||||||
|
'url' => 'registry.keystone.internal:5000',
|
||||||
|
'storage_path' => '/home/keystone/registry/data',
|
||||||
|
'control_server_id' => $control->id,
|
||||||
|
'readiness_checks' => [
|
||||||
|
'storage_writable' => true,
|
||||||
|
'http_reachable' => true,
|
||||||
|
'auth_configured' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->managedRegistry->markHealthy('Registry online and serving manifests');
|
||||||
|
|
||||||
|
$organisation->registries()->create([
|
||||||
|
'name' => 'GitHub Container Registry',
|
||||||
|
'type' => RegistryType::GHCR,
|
||||||
|
'url' => 'ghcr.io',
|
||||||
|
'credentials' => [
|
||||||
|
'username' => 'keystone-bot',
|
||||||
|
'token' => Str::password(40),
|
||||||
|
],
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'health_message' => 'Authenticated successfully',
|
||||||
|
'health_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSourceProvider(Organisation $organisation): \App\Models\SourceProvider
|
||||||
|
{
|
||||||
|
return $organisation->sourceProviders()->create([
|
||||||
|
'name' => 'Gitea',
|
||||||
|
'type' => SourceProviderType::GITEA,
|
||||||
|
'url' => 'https://git.keystone.internal',
|
||||||
|
'config' => [
|
||||||
|
'api_url' => 'https://git.keystone.internal/api/v1',
|
||||||
|
'organisation' => 'stratbucket',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedApplication(Organisation $organisation, \App\Models\SourceProvider $sourceProvider): Application
|
||||||
|
{
|
||||||
|
return $organisation->applications()->create([
|
||||||
|
'name' => 'ClipBin',
|
||||||
|
'source_provider_id' => $sourceProvider->id,
|
||||||
|
'repository_url' => 'git@git.keystone.internal:stratbucket/clipbin.git',
|
||||||
|
'repository_type' => RepositoryType::GIT,
|
||||||
|
'default_branch' => 'main',
|
||||||
|
'deploy_key_installed_at' => now()->subWeeks(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedEnvironment(
|
||||||
|
Application $application,
|
||||||
|
Server $control,
|
||||||
|
Collection $workers,
|
||||||
|
string $name,
|
||||||
|
string $branch,
|
||||||
|
): Environment {
|
||||||
|
$environment = app(CreateLaravelEnvironment::class)->execute($application, $name, $branch);
|
||||||
|
|
||||||
|
$web = $environment->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
|
||||||
|
$web->forceFill(['server_id' => $workers->first()->id])->save();
|
||||||
|
$this->createReplica($web, $workers->first(), 80);
|
||||||
|
|
||||||
|
$postgres = $this->createDependencyService(
|
||||||
|
$environment, $workers->get(1), 'postgres',
|
||||||
|
ServiceCategory::DATABASE, ServiceType::POSTGRES, '18', 5432, DeployPolicy::DEPENDENCY_ONLY,
|
||||||
|
);
|
||||||
|
$valkey = $this->createDependencyService(
|
||||||
|
$environment, $workers->get(2), 'valkey',
|
||||||
|
ServiceCategory::CACHE, ServiceType::VALKEY, '8', 6379, DeployPolicy::DEPENDENCY_ONLY,
|
||||||
|
);
|
||||||
|
$caddy = $this->createDependencyService(
|
||||||
|
$environment, $control, 'gateway',
|
||||||
|
ServiceCategory::GATEWAY, ServiceType::CADDY, '2', 80, DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(AttachManagedService::class)->execute($environment, $postgres, EnvironmentAttachmentRole::DATABASE, isPrimary: true);
|
||||||
|
app(AttachManagedService::class)->execute($environment, $valkey, EnvironmentAttachmentRole::CACHE);
|
||||||
|
app(AttachManagedService::class)->execute($environment, $caddy, EnvironmentAttachmentRole::GATEWAY);
|
||||||
|
|
||||||
|
foreach ([$web, $postgres, $valkey, $caddy] as $service) {
|
||||||
|
foreach ($service->replicas()->get() as $replica) {
|
||||||
|
app(RegisterServiceEndpoint::class)->execute($replica);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->seedUserVariables($environment);
|
||||||
|
$this->advanceToRunning($environment);
|
||||||
|
|
||||||
|
return $environment->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDependencyService(
|
||||||
|
Environment $environment,
|
||||||
|
Server $server,
|
||||||
|
string $name,
|
||||||
|
ServiceCategory $category,
|
||||||
|
ServiceType $type,
|
||||||
|
string $version,
|
||||||
|
int $port,
|
||||||
|
DeployPolicy $deployPolicy,
|
||||||
|
): Service {
|
||||||
|
$service = $environment->services()->create([
|
||||||
|
'organisation_id' => $environment->application->organisation_id,
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'name' => $name,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'version' => $version,
|
||||||
|
'version_track' => $version,
|
||||||
|
'driver_name' => "{$type->value}.{$version}",
|
||||||
|
'status' => ServiceStatus::NOT_INSTALLED,
|
||||||
|
'deploy_policy' => $deployPolicy,
|
||||||
|
'process_roles' => [],
|
||||||
|
'desired_replicas' => 1,
|
||||||
|
'config' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (method_exists($service->driver(), 'defaultCredentials')) {
|
||||||
|
$service->credentials = $service->driver()->defaultCredentials();
|
||||||
|
$service->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createReplica($service, $server, $port);
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createReplica(Service $service, Server $server, int $port): void
|
||||||
|
{
|
||||||
|
$service->replicas()->create([
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'container_name' => "keystone-service-{$service->id}-1",
|
||||||
|
'internal_host' => "keystone-service-{$service->id}",
|
||||||
|
'internal_port' => $port,
|
||||||
|
'status' => 'pending',
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'config' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedUserVariables(Environment $environment): void
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
'APP_NAME' => 'ClipBin',
|
||||||
|
'APP_ENV' => $environment->name,
|
||||||
|
'LOG_CHANNEL' => 'stderr',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
$environment->variables()->updateOrCreate(['key' => $key], [
|
||||||
|
'value' => $value,
|
||||||
|
'source' => EnvironmentVariableSource::USER,
|
||||||
|
'overridable' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function advanceToRunning(Environment $environment): void
|
||||||
|
{
|
||||||
|
$environment->forceFill(['status' => 'active'])->save();
|
||||||
|
|
||||||
|
foreach ($environment->services()->with('replicas', 'slices')->get() as $service) {
|
||||||
|
$digest = $this->digest();
|
||||||
|
|
||||||
|
$service->forceFill([
|
||||||
|
'status' => ServiceStatus::RUNNING,
|
||||||
|
'current_image_digest' => $digest,
|
||||||
|
'available_image_digest' => $digest,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$service->replicas->each->forceFill([
|
||||||
|
'status' => 'running',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'image_digest' => $digest,
|
||||||
|
])->each->save();
|
||||||
|
|
||||||
|
$service->slices->each->forceFill(['status' => 'active'])->each->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->completeSliceProvisionOperations($environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completeSliceProvisionOperations(Environment $environment): void
|
||||||
|
{
|
||||||
|
foreach ($environment->services()->with('slices.operations.steps')->get() as $service) {
|
||||||
|
foreach ($service->slices as $slice) {
|
||||||
|
foreach ($slice->operations as $operation) {
|
||||||
|
$operation->forceFill([
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subMinutes(8),
|
||||||
|
'finished_at' => now()->subMinutes(7),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$operation->steps->each->forceFill([
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'logs' => 'Slice provisioned successfully.',
|
||||||
|
'started_at' => now()->subMinutes(8),
|
||||||
|
'finished_at' => now()->subMinutes(7),
|
||||||
|
])->each->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedVariety(Environment $production, Environment $staging): void
|
||||||
|
{
|
||||||
|
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
|
||||||
|
$stagingValkey->replicas()->update(['health_status' => 'unhealthy']);
|
||||||
|
|
||||||
|
$production->buildArtifacts()->create([
|
||||||
|
'commit_sha' => $this->sha(),
|
||||||
|
'image_tag' => 'clipbin:production-'.Str::random(7),
|
||||||
|
'image_digest' => $this->digest(),
|
||||||
|
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
|
||||||
|
'built_by_service_id' => $production->services()->where('type', ServiceType::LARAVEL)->value('id'),
|
||||||
|
'status' => BuildArtifactStatus::AVAILABLE,
|
||||||
|
'metadata' => ['branch' => 'main', 'build_seconds' => 142],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staging->buildArtifacts()->create([
|
||||||
|
'commit_sha' => $this->sha(),
|
||||||
|
'image_tag' => 'clipbin:staging-'.Str::random(7),
|
||||||
|
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
|
||||||
|
'built_by_service_id' => $staging->services()->where('type', ServiceType::LARAVEL)->value('id'),
|
||||||
|
'status' => BuildArtifactStatus::BUILDING,
|
||||||
|
'metadata' => ['branch' => 'develop'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedOperationsHistory(Server $control, Environment $production, Environment $staging): void
|
||||||
|
{
|
||||||
|
$registryOp = $control->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_PROVISION,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subDays(5),
|
||||||
|
'finished_at' => now()->subDays(5)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
$registryOp->steps()->create([
|
||||||
|
'name' => 'Provision managed registry',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'docker run -d --name keystone-registry registry:2',
|
||||||
|
'logs' => 'Registry container started and reachable.',
|
||||||
|
'started_at' => now()->subDays(5),
|
||||||
|
'finished_at' => now()->subDays(5)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serverOp = $control->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVER_PROVISION,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subDays(6),
|
||||||
|
'finished_at' => now()->subDays(6)->addMinutes(9),
|
||||||
|
]);
|
||||||
|
$serverOp->steps()->create([
|
||||||
|
'name' => 'Install container runtime',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'apt-get install -y docker-ce',
|
||||||
|
'logs' => 'Docker installed, daemon active.',
|
||||||
|
'started_at' => now()->subDays(6),
|
||||||
|
'finished_at' => now()->subDays(6)->addMinutes(9),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deployOp = $production->operations()->create([
|
||||||
|
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subHours(3),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
$deployOp->steps()->create([
|
||||||
|
'name' => 'Build application image',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'keystone build --env production',
|
||||||
|
'logs' => 'Image built and pushed to managed registry.',
|
||||||
|
'started_at' => now()->subHours(3),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$web = $production->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
|
||||||
|
$serviceOp = $web->operations()->create([
|
||||||
|
'parent_id' => $deployOp->id,
|
||||||
|
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
$serviceOp->steps()->create([
|
||||||
|
'name' => 'Roll out web replica',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'keystone service:deploy web',
|
||||||
|
'logs' => 'Replica healthy, traffic switched.',
|
||||||
|
'started_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inProgress = $staging->operations()->create([
|
||||||
|
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
|
||||||
|
'status' => OperationStatus::IN_PROGRESS,
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
|
$inProgress->steps()->create([
|
||||||
|
'name' => 'Build application image',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::IN_PROGRESS,
|
||||||
|
'script' => 'keystone build --env staging',
|
||||||
|
'logs' => 'Compiling assets...',
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
|
||||||
|
$failedOp = $stagingValkey->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||||
|
'status' => OperationStatus::FAILED,
|
||||||
|
'started_at' => now()->subHours(1),
|
||||||
|
'finished_at' => now()->subHours(1)->addMinutes(2),
|
||||||
|
]);
|
||||||
|
$failedOp->steps()->create([
|
||||||
|
'name' => 'Start valkey replica',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::FAILED,
|
||||||
|
'script' => 'keystone service:deploy valkey',
|
||||||
|
'logs' => 'Pulling image valkey/valkey:8...',
|
||||||
|
'error_logs' => 'Health check failed: connection refused on 6379 after 30s.',
|
||||||
|
'started_at' => now()->subHours(1),
|
||||||
|
'finished_at' => now()->subHours(1)->addMinutes(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function digest(): string
|
||||||
|
{
|
||||||
|
return 'sha256:'.hash('sha256', Str::uuid()->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sha(): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha1', Str::uuid()->toString()), 0, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class TestEnvironmentSeeder extends Seeder
|
|||||||
$organisation->providers()->create([
|
$organisation->providers()->create([
|
||||||
'name' => 'Hetzner',
|
'name' => 'Hetzner',
|
||||||
'type' => ProviderType::HETZNER,
|
'type' => ProviderType::HETZNER,
|
||||||
'token' => env('HETZNER_KEY'),
|
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -24,9 +27,9 @@ import type { BreadcrumbItem, NavItem } from "@/types";
|
|||||||
import { Link, usePage } from "@inertiajs/vue3";
|
import { Link, usePage } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
AppWindowIcon,
|
AppWindowIcon,
|
||||||
BoltIcon,
|
|
||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
ClipboardListIcon,
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -45,7 +48,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
const isCurrentRoute = computed(() => (url: string) => page.url === url || page.url.startsWith(`${url}/`));
|
const isCurrentRoute = computed(
|
||||||
|
() => (url: string) => page.url === url || page.url.startsWith(`${url}/`),
|
||||||
|
);
|
||||||
|
|
||||||
const activeItemStyles = computed(
|
const activeItemStyles = computed(
|
||||||
() => (url: string) =>
|
() => (url: string) =>
|
||||||
@@ -62,17 +67,21 @@ const mainNavItems: NavItem[] = [
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const activeOrganisation = computed(() => page.props.organisation);
|
||||||
|
|
||||||
|
const organisations = computed(() => auth.value.user?.organisations ?? []);
|
||||||
|
|
||||||
if (page.props.organisation) {
|
if (page.props.organisation) {
|
||||||
const organisationId = page.props?.organisation?.id;
|
const organisationId = page.props?.organisation?.id;
|
||||||
|
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: page.props.organisation.name,
|
title: "Applications",
|
||||||
href: new URL(
|
href: new URL(
|
||||||
route("organisations.show", {
|
route("applications.index", {
|
||||||
organisation: organisationId,
|
organisation: organisationId,
|
||||||
}),
|
}),
|
||||||
).pathname,
|
).pathname,
|
||||||
icon: BoltIcon,
|
icon: AppWindowIcon,
|
||||||
});
|
});
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: "Environments",
|
title: "Environments",
|
||||||
@@ -83,15 +92,6 @@ if (page.props.organisation) {
|
|||||||
).pathname,
|
).pathname,
|
||||||
icon: BoxesIcon,
|
icon: BoxesIcon,
|
||||||
});
|
});
|
||||||
mainNavItems.push({
|
|
||||||
title: "Applications",
|
|
||||||
href: new URL(
|
|
||||||
route("applications.index", {
|
|
||||||
organisation: organisationId,
|
|
||||||
}),
|
|
||||||
).pathname,
|
|
||||||
icon: AppWindowIcon,
|
|
||||||
});
|
|
||||||
mainNavItems.push({
|
mainNavItems.push({
|
||||||
title: "Servers",
|
title: "Servers",
|
||||||
href: new URL(
|
href: new URL(
|
||||||
@@ -110,24 +110,6 @@ if (page.props.organisation) {
|
|||||||
).pathname,
|
).pathname,
|
||||||
icon: WorkflowIcon,
|
icon: WorkflowIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
page.props.organisation.providers_count === 0 ||
|
|
||||||
page.props.organisation.source_providers_count === 0 ||
|
|
||||||
page.props.organisation.registries_count === 0 ||
|
|
||||||
page.props.organisation.servers_count === 0 ||
|
|
||||||
page.props.organisation.applications_count === 0
|
|
||||||
) {
|
|
||||||
mainNavItems.push({
|
|
||||||
title: "Onboarding",
|
|
||||||
href: new URL(
|
|
||||||
route("onboarding.show", {
|
|
||||||
organisation: organisationId,
|
|
||||||
}),
|
|
||||||
).pathname,
|
|
||||||
icon: ClipboardListIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightNavItems: NavItem[] = [
|
const rightNavItems: NavItem[] = [
|
||||||
@@ -206,6 +188,36 @@ const rightNavItems: NavItem[] = [
|
|||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<!-- Organisation switcher -->
|
||||||
|
<DropdownMenu v-if="activeOrganisation">
|
||||||
|
<DropdownMenuTrigger :as-child="true">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-2 h-9 max-w-[12rem] gap-1.5 px-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ activeOrganisation.name }}</span>
|
||||||
|
<ChevronsUpDown class="size-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" class="w-60">
|
||||||
|
<DropdownMenuLabel>Organisations</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="organisation in organisations"
|
||||||
|
:key="organisation.id"
|
||||||
|
:as="Link"
|
||||||
|
:href="route('organisations.show', { organisation: organisation.id })"
|
||||||
|
class="cursor-pointer justify-between"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ organisation.name }}</span>
|
||||||
|
<Check
|
||||||
|
v-if="organisation.id === activeOrganisation.id"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<!-- Desktop Menu -->
|
<!-- Desktop Menu -->
|
||||||
<div class="hidden h-full lg:flex lg:flex-1">
|
<div class="hidden h-full lg:flex lg:flex-1">
|
||||||
<NavigationMenu class="ml-10 flex h-full items-stretch">
|
<NavigationMenu class="ml-10 flex h-full items-stretch">
|
||||||
|
|||||||
91
resources/js/components/StatusIndicator.vue
Normal file
91
resources/js/components/StatusIndicator.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
status?: string | null;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
status: "unknown",
|
||||||
|
size: "sm",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type Tone = "positive" | "negative" | "pending" | "neutral";
|
||||||
|
|
||||||
|
const tones: Record<string, Tone> = {
|
||||||
|
active: "positive",
|
||||||
|
running: "positive",
|
||||||
|
succeeded: "positive",
|
||||||
|
success: "positive",
|
||||||
|
completed: "positive",
|
||||||
|
ready: "positive",
|
||||||
|
verified: "positive",
|
||||||
|
enabled: "positive",
|
||||||
|
healthy: "positive",
|
||||||
|
stopped: "negative",
|
||||||
|
failed: "negative",
|
||||||
|
error: "negative",
|
||||||
|
errored: "negative",
|
||||||
|
unhealthy: "negative",
|
||||||
|
cancelled: "negative",
|
||||||
|
installing: "pending",
|
||||||
|
pending: "pending",
|
||||||
|
"in-progress": "pending",
|
||||||
|
building: "pending",
|
||||||
|
planning: "pending",
|
||||||
|
deploying: "pending",
|
||||||
|
provisioning: "pending",
|
||||||
|
queued: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalised = computed(() => (props.status ?? "unknown").toString().toLowerCase().trim());
|
||||||
|
|
||||||
|
const tone = computed<Tone>(() => tones[normalised.value] ?? "neutral");
|
||||||
|
|
||||||
|
const label = computed(
|
||||||
|
() =>
|
||||||
|
props.label ??
|
||||||
|
(props.status ?? "unknown").toString().replaceAll("-", " ").replaceAll("_", " "),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dotClasses = computed(() => {
|
||||||
|
switch (tone.value) {
|
||||||
|
case "positive":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "negative":
|
||||||
|
return "bg-red-500";
|
||||||
|
case "pending":
|
||||||
|
return "bg-amber-500 animate-pulse";
|
||||||
|
default:
|
||||||
|
return "bg-zinc-400 dark:bg-zinc-500";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const textClasses = computed(() => {
|
||||||
|
switch (tone.value) {
|
||||||
|
case "positive":
|
||||||
|
return "text-green-700 dark:text-green-400";
|
||||||
|
case "negative":
|
||||||
|
return "text-red-700 dark:text-red-400";
|
||||||
|
case "pending":
|
||||||
|
return "text-amber-700 dark:text-amber-400";
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full"
|
||||||
|
:class="[dotClasses, size === 'md' ? 'size-2' : 'size-1.5']"
|
||||||
|
/>
|
||||||
|
<span class="capitalize" :class="[textClasses, size === 'md' ? 'text-sm' : 'text-xs']">{{
|
||||||
|
label
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -5,17 +5,20 @@ import ServiceStatus from "@/enums/ServiceStatus";
|
|||||||
import ServiceType from "@/enums/ServiceType";
|
import ServiceType from "@/enums/ServiceType";
|
||||||
import { DoorOpenIcon } from "lucide-vue-next";
|
import { DoorOpenIcon } from "lucide-vue-next";
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(
|
||||||
icon?: object | Function;
|
defineProps<{
|
||||||
serviceType?: string;
|
icon?: object | Function;
|
||||||
serviceCategory?: string;
|
serviceType?: string;
|
||||||
status?: string;
|
serviceCategory?: string;
|
||||||
}>(), {
|
status?: string;
|
||||||
icon: () => DoorOpenIcon,
|
}>(),
|
||||||
serviceType: ServiceType.GATEWAY,
|
{
|
||||||
serviceCategory: ServiceCategory.DATABASE,
|
icon: () => DoorOpenIcon,
|
||||||
status: ServiceStatus.UNKNOWN,
|
serviceType: ServiceType.GATEWAY,
|
||||||
});
|
serviceCategory: ServiceCategory.DATABASE,
|
||||||
|
status: ServiceStatus.UNKNOWN,
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
19
resources/js/components/environments/SpecRow.vue
Normal file
19
resources/js/components/environments/SpecRow.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
icon?: object | Function;
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<component :is="icon" v-if="icon" class="size-4 opacity-70" />
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-right font-medium">
|
||||||
|
<slot>{{ value ?? "—" }}</slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
29
resources/js/components/environments/TopologyCard.vue
Normal file
29
resources/js/components/environments/TopologyCard.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import StatusIndicator from "@/components/StatusIndicator.vue";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
icon?: object | Function;
|
||||||
|
status?: string | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b bg-muted/30 px-4 py-3">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<component :is="icon" v-if="icon" class="size-4 shrink-0 opacity-70" />
|
||||||
|
<span class="truncate font-semibold">{{ title }}</span>
|
||||||
|
<span v-if="subtitle" class="truncate text-xs text-muted-foreground">{{
|
||||||
|
subtitle
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<StatusIndicator v-if="status" :status="status" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2.5 p-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -13,7 +13,8 @@ defineProps<{
|
|||||||
|
|
||||||
const selectedStep = ref<Record<string, any> | null>(null);
|
const selectedStep = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
const label = (value?: string | null): string =>
|
||||||
|
value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
||||||
|
|
||||||
const targetLabel = (target?: Record<string, any> | null): string => {
|
const targetLabel = (target?: Record<string, any> | null): string => {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -74,9 +75,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
|
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
|
||||||
<Badge
|
<Badge :variant="step.status === 'completed' ? 'success' : 'secondary'">
|
||||||
:variant="step.status === 'completed' ? 'success' : 'secondary'"
|
|
||||||
>
|
|
||||||
{{ label(step.status) }}
|
{{ label(step.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,9 +114,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
|
|||||||
>
|
>
|
||||||
{{ label(child.kind) }}
|
{{ label(child.kind) }}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge
|
<Badge :variant="child.status === 'completed' ? 'success' : 'secondary'">
|
||||||
:variant="child.status === 'completed' ? 'success' : 'secondary'"
|
|
||||||
>
|
|
||||||
{{ label(child.status) }}
|
{{ label(child.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
|
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
|
||||||
@@ -126,23 +123,35 @@ const targetLabel = (target?: Record<string, any> | null): string => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="operations.length === 0" class="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
|
<div
|
||||||
|
v-if="operations.length === 0"
|
||||||
|
class="rounded-md border border-dashed p-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
No operations recorded yet.
|
No operations recorded yet.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog :open="!!selectedStep" @update:open="($event) => (!$event ? (selectedStep = null) : null)">
|
<Dialog
|
||||||
|
:open="!!selectedStep"
|
||||||
|
@update:open="($event) => (!$event ? (selectedStep = null) : null)"
|
||||||
|
>
|
||||||
<DialogContent class="md:max-w-3xl">
|
<DialogContent class="md:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
|
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<section v-if="selectedStep?.logs">
|
<section v-if="selectedStep?.logs">
|
||||||
<h3 class="text-sm font-medium">Logs</h3>
|
<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>
|
<pre
|
||||||
|
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
|
||||||
|
>{{ selectedStep.logs }}</pre
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="selectedStep?.error_logs">
|
<section v-if="selectedStep?.error_logs">
|
||||||
<h3 class="text-sm font-medium">Error Logs</h3>
|
<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>
|
<pre
|
||||||
|
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
|
||||||
|
>{{ selectedStep.error_logs }}</pre
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
19
resources/js/components/ui/select/Select.vue
Normal file
19
resources/js/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
SelectRoot,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
type SelectRootEmits,
|
||||||
|
type SelectRootProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>();
|
||||||
|
const emits = defineEmits<SelectRootEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
60
resources/js/components/ui/select/SelectContent.vue
Normal file
60
resources/js/components/ui/select/SelectContent.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import SelectScrollDownButton from "./SelectScrollDownButton.vue";
|
||||||
|
import SelectScrollUpButton from "./SelectScrollUpButton.vue";
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
type SelectContentEmits,
|
||||||
|
type SelectContentProps,
|
||||||
|
SelectPortal,
|
||||||
|
SelectViewport,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
|
{
|
||||||
|
position: "popper",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emits = defineEmits<SelectContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectViewport
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
21
resources/js/components/ui/select/SelectGroup.vue
Normal file
21
resources/js/components/ui/select/SelectGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SelectGroup, type SelectGroupProps, useForwardProps } from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup v-bind="forwardedProps" :class="cn('p-1', props.class)">
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
43
resources/js/components/ui/select/SelectItem.vue
Normal file
43
resources/js/components/ui/select/SelectItem.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Check } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
type SelectItemProps,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<Check class="size-4" />
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
13
resources/js/components/ui/select/SelectLabel.vue
Normal file
13
resources/js/components/ui/select/SelectLabel.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SelectLabel, type SelectLabelProps } from "radix-vue";
|
||||||
|
import { type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectLabel :class="cn('px-2 py-1.5 text-sm font-semibold', props.class)">
|
||||||
|
<slot />
|
||||||
|
</SelectLabel>
|
||||||
|
</template>
|
||||||
31
resources/js/components/ui/select/SelectScrollDownButton.vue
Normal file
31
resources/js/components/ui/select/SelectScrollDownButton.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChevronDown } from "lucide-vue-next";
|
||||||
|
import {
|
||||||
|
SelectScrollDownButton,
|
||||||
|
type SelectScrollDownButtonProps,
|
||||||
|
useForwardProps,
|
||||||
|
} from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollDownButton
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDown class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
|
</template>
|
||||||
27
resources/js/components/ui/select/SelectScrollUpButton.vue
Normal file
27
resources/js/components/ui/select/SelectScrollUpButton.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChevronUp } from "lucide-vue-next";
|
||||||
|
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollUpButton
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUp class="size-4" />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
|
</template>
|
||||||
11
resources/js/components/ui/select/SelectSeparator.vue
Normal file
11
resources/js/components/ui/select/SelectSeparator.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SelectSeparator, type SelectSeparatorProps } from "radix-vue";
|
||||||
|
import { type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectSeparator :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||||
|
</template>
|
||||||
33
resources/js/components/ui/select/SelectTrigger.vue
Normal file
33
resources/js/components/ui/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChevronDown } from "lucide-vue-next";
|
||||||
|
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from "radix-vue";
|
||||||
|
import { computed, type HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>();
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props;
|
||||||
|
|
||||||
|
return delegated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectTrigger
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 data-[placeholder]:text-muted-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDown class="size-4 opacity-50" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
</template>
|
||||||
11
resources/js/components/ui/select/SelectValue.vue
Normal file
11
resources/js/components/ui/select/SelectValue.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SelectValue, type SelectValueProps } from "radix-vue";
|
||||||
|
|
||||||
|
const props = defineProps<SelectValueProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectValue v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</SelectValue>
|
||||||
|
</template>
|
||||||
10
resources/js/components/ui/select/index.ts
Normal file
10
resources/js/components/ui/select/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Select } from "./Select.vue";
|
||||||
|
export { default as SelectContent } from "./SelectContent.vue";
|
||||||
|
export { default as SelectGroup } from "./SelectGroup.vue";
|
||||||
|
export { default as SelectItem } from "./SelectItem.vue";
|
||||||
|
export { default as SelectLabel } from "./SelectLabel.vue";
|
||||||
|
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
|
||||||
|
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
|
||||||
|
export { default as SelectSeparator } from "./SelectSeparator.vue";
|
||||||
|
export { default as SelectTrigger } from "./SelectTrigger.vue";
|
||||||
|
export { default as SelectValue } from "./SelectValue.vue";
|
||||||
@@ -1,102 +1,259 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import StatusIndicator from "@/components/StatusIndicator.vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { type BreadcrumbItem } from "@/types";
|
import { type BreadcrumbItem } from "@/types";
|
||||||
import { Head, Link } from "@inertiajs/vue3";
|
import { Head, Link, usePage } from "@inertiajs/vue3";
|
||||||
import { ChevronRightIcon } from "lucide-vue-next";
|
import { GitBranchIcon, PlusIcon } from "lucide-vue-next";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
organisations: Record<string, any>[];
|
organisations: Record<string, any>[];
|
||||||
recentOperations: Record<string, any>[];
|
recentApplications: Record<string, any>[];
|
||||||
|
deployments: Record<string, any>[];
|
||||||
unhealthyServices: Record<string, any>[];
|
unhealthyServices: Record<string, any>[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const organisation = computed(() => page.props.organisation);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
href: "/dashboard",
|
href: "/dashboard",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const initials = (name: string): string =>
|
||||||
|
name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const timeAgo = (value?: string | null): string => {
|
||||||
|
if (!value) {
|
||||||
|
return "never";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
|
||||||
|
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
const divisions: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||||
|
{ amount: 60, unit: "seconds" },
|
||||||
|
{ amount: 60, unit: "minutes" },
|
||||||
|
{ amount: 24, unit: "hours" },
|
||||||
|
{ amount: 7, unit: "days" },
|
||||||
|
{ amount: 4.34524, unit: "weeks" },
|
||||||
|
{ amount: 12, unit: "months" },
|
||||||
|
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let duration = -seconds;
|
||||||
|
for (const division of divisions) {
|
||||||
|
if (Math.abs(duration) < division.amount) {
|
||||||
|
return formatter.format(Math.round(duration), division.unit);
|
||||||
|
}
|
||||||
|
duration /= division.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "just now";
|
||||||
|
};
|
||||||
|
|
||||||
|
const deploymentTarget = (operation: Record<string, any>): Record<string, any> | null => {
|
||||||
|
const service = operation.target;
|
||||||
|
const environment = service?.environment;
|
||||||
|
const application = environment?.application;
|
||||||
|
|
||||||
|
if (!service || !environment || !application) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
environment,
|
||||||
|
application,
|
||||||
|
href: route("environments.show", {
|
||||||
|
organisation: application.organisation_id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3">
|
<div class="mx-auto w-full max-w-7xl space-y-8 p-4">
|
||||||
<Card class="lg:col-span-2">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<CardHeader class="border-b-muted-background border-b">
|
<div>
|
||||||
<CardTitle>Organisations</CardTitle>
|
<h1 class="text-2xl font-bold tracking-tight">Overview</h1>
|
||||||
<CardDescription>Select an organisation to view its environments.</CardDescription>
|
<p v-if="organisation" class="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
{{ organisation.name }}
|
||||||
<CardContent class="divide-y-muted-foreground divide-y p-0">
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="organisation"
|
||||||
|
:as="Link"
|
||||||
|
:href="route('applications.create', { organisation: organisation.id })"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
New application
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-sm font-medium text-muted-foreground">Recent applications</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link
|
<Link
|
||||||
v-for="organisation in organisations"
|
v-for="application in recentApplications"
|
||||||
:key="organisation.id"
|
:key="application.id"
|
||||||
:href="route('organisations.show', { organisation: organisation.id })"
|
:href="
|
||||||
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
|
route('applications.show', {
|
||||||
|
organisation: application.organisation_id,
|
||||||
|
application: application.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="block"
|
||||||
>
|
>
|
||||||
<div>
|
<Card class="h-full transition-colors hover:border-foreground/20">
|
||||||
<div class="font-medium">{{ organisation.name }}</div>
|
<CardContent class="flex flex-col gap-4 p-5">
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="flex items-start gap-3">
|
||||||
{{ organisation.applications_count }} applications ·
|
<div
|
||||||
{{ organisation.servers_count }} servers ·
|
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-sm font-semibold"
|
||||||
{{ organisation.services_count }} services
|
>
|
||||||
</div>
|
{{ initials(application.name) }}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon class="size-4 text-muted-foreground" />
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate font-medium">
|
||||||
|
{{ application.name }}
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-sm text-muted-foreground">
|
||||||
|
{{
|
||||||
|
application.source_provider?.name ??
|
||||||
|
"No source provider"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusIndicator
|
||||||
|
v-if="application.environments?.[0]"
|
||||||
|
:status="application.environments[0].status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<GitBranchIcon class="size-3.5" />
|
||||||
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||||
|
{{ application.environments?.[0]?.branch ?? "—" }}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span
|
||||||
|
>{{
|
||||||
|
application.environments?.length ?? 0
|
||||||
|
}}
|
||||||
|
environments</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card
|
||||||
<CardHeader>
|
v-if="recentApplications.length === 0"
|
||||||
<CardTitle>Unhealthy services</CardTitle>
|
class="col-span-full border-dashed"
|
||||||
<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>
|
<CardContent class="flex flex-col items-center gap-3 p-8 text-center">
|
||||||
<div class="text-muted-foreground">{{ service.status }}</div>
|
<p class="text-sm text-muted-foreground">No applications yet.</p>
|
||||||
</div>
|
<Button
|
||||||
<div
|
v-if="organisation"
|
||||||
v-if="unhealthyServices.length === 0"
|
:as="Link"
|
||||||
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
size="sm"
|
||||||
>
|
:href="
|
||||||
No unhealthy services.
|
route('applications.create', { organisation: organisation.id })
|
||||||
</div>
|
"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<PlusIcon class="size-4" />
|
||||||
|
Create your first application
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Card class="lg:col-span-3">
|
<section class="grid gap-6 lg:grid-cols-3">
|
||||||
<CardHeader>
|
<Card class="lg:col-span-2">
|
||||||
<CardTitle>Recent operations</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Latest service operations across your organisations.</CardDescription>
|
<CardTitle>Latest deployments</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription
|
||||||
<CardContent class="grid gap-2">
|
>Recent operations across your organisations.</CardDescription
|
||||||
<div
|
>
|
||||||
v-for="operation in recentOperations"
|
</CardHeader>
|
||||||
:key="operation.id"
|
<CardContent class="p-0">
|
||||||
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
<ul class="divide-y">
|
||||||
>
|
<li
|
||||||
<div>
|
v-for="operation in deployments"
|
||||||
<div class="font-medium">{{ operation.kind.replace("_", " ") }}</div>
|
:key="operation.id"
|
||||||
<div class="text-muted-foreground">{{ operation.hash }}</div>
|
class="flex items-center gap-3 px-6 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<StatusIndicator :status="operation.status" />
|
||||||
|
<span class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ operation.hash?.slice(0, 8) }}
|
||||||
|
</span>
|
||||||
|
<component
|
||||||
|
:is="deploymentTarget(operation) ? Link : 'span'"
|
||||||
|
:href="deploymentTarget(operation)?.href"
|
||||||
|
class="min-w-0 flex-1 truncate"
|
||||||
|
:class="deploymentTarget(operation) ? 'hover:underline' : ''"
|
||||||
|
>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ deploymentTarget(operation)?.application?.name ?? "—" }}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{{ operation.kind?.replaceAll("_", " ") }}
|
||||||
|
</span>
|
||||||
|
</component>
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{{ timeAgo(operation.finished_at ?? operation.created_at) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="deployments.length === 0"
|
||||||
|
class="px-6 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No deployments recorded.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unhealthy services</CardTitle>
|
||||||
|
<CardDescription>Services that need attention.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-2">
|
||||||
|
<div
|
||||||
|
v-for="service in unhealthyServices"
|
||||||
|
:key="service.id"
|
||||||
|
class="flex items-center justify-between rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ service.name }}</span>
|
||||||
|
<StatusIndicator :status="service.status" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground">{{ operation.status.replace("-", " ") }}</div>
|
<div
|
||||||
</div>
|
v-if="unhealthyServices.length === 0"
|
||||||
<div
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
||||||
v-if="recentOperations.length === 0"
|
>
|
||||||
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
No unhealthy services.
|
||||||
>
|
</div>
|
||||||
No operations recorded.
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</section>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, useForm } from "@inertiajs/vue3";
|
import { Head, Link, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -60,41 +67,44 @@ const form = useForm({
|
|||||||
<CardContent class="grid gap-3 text-sm">
|
<CardContent class="grid gap-3 text-sm">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="repository_type">Repository type</Label>
|
<Label for="repository_type">Repository type</Label>
|
||||||
<select
|
<Select v-model="form.repository_type" required>
|
||||||
id="repository_type"
|
<SelectTrigger id="repository_type">
|
||||||
v-model="form.repository_type"
|
<SelectValue placeholder="Select a type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
required
|
<SelectContent>
|
||||||
>
|
<SelectItem
|
||||||
<option
|
v-for="(type, key) in repositoryTypes"
|
||||||
v-for="(type, key) in repositoryTypes"
|
:key="key"
|
||||||
:key="key"
|
:value="type"
|
||||||
:value="type"
|
>
|
||||||
>
|
{{ type }}
|
||||||
{{ type }}
|
</SelectItem>
|
||||||
</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="form.errors.repository_type" />
|
<InputError :message="form.errors.repository_type" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sourceProviders.length" class="grid gap-2">
|
<div v-if="sourceProviders.length" class="grid gap-2">
|
||||||
<Label for="source_provider_id">Source provider</Label>
|
<Label for="source_provider_id">Source provider</Label>
|
||||||
<select
|
<Select v-model="form.source_provider_id">
|
||||||
id="source_provider_id"
|
<SelectTrigger id="source_provider_id">
|
||||||
v-model="form.source_provider_id"
|
<SelectValue placeholder="No provider" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">No provider</option>
|
<SelectItem
|
||||||
<option
|
v-for="provider in sourceProviders"
|
||||||
v-for="provider in sourceProviders"
|
:key="provider.id"
|
||||||
:key="provider.id"
|
:value="String(provider.id)"
|
||||||
:value="provider.id"
|
>
|
||||||
>
|
{{ provider.name }} · {{ provider.type }}
|
||||||
{{ provider.name }} · {{ provider.type }}
|
</SelectItem>
|
||||||
</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="form.errors.source_provider_id" />
|
<InputError :message="form.errors.source_provider_id" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3">
|
<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">
|
<span class="text-muted-foreground">
|
||||||
No source provider is configured yet. SSH URLs still work, but adding a
|
No source provider is configured yet. SSH URLs still work, but adding a
|
||||||
provider documents which Git host this repository belongs to.
|
provider documents which Git host this repository belongs to.
|
||||||
@@ -103,7 +113,11 @@ const form = useForm({
|
|||||||
:as="Link"
|
:as="Link"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:href="route('source-providers.create', { organisation: $page.props.organisation.id })"
|
:href="
|
||||||
|
route('source-providers.create', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Add provider
|
Add provider
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -79,38 +86,38 @@ const destroyApplication = (): void => {
|
|||||||
<CardContent class="grid gap-4">
|
<CardContent class="grid gap-4">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="repository_type">Repository type</Label>
|
<Label for="repository_type">Repository type</Label>
|
||||||
<select
|
<Select v-model="form.repository_type" required>
|
||||||
id="repository_type"
|
<SelectTrigger id="repository_type">
|
||||||
v-model="form.repository_type"
|
<SelectValue placeholder="Select repository type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
required
|
<SelectContent>
|
||||||
>
|
<SelectItem
|
||||||
<option
|
v-for="(type, key) in repositoryTypes"
|
||||||
v-for="(type, key) in repositoryTypes"
|
:key="key"
|
||||||
:key="key"
|
:value="type"
|
||||||
:value="type"
|
>
|
||||||
>
|
{{ type }}
|
||||||
{{ type }}
|
</SelectItem>
|
||||||
</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="form.errors.repository_type" />
|
<InputError :message="form.errors.repository_type" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="source_provider_id">Source provider</Label>
|
<Label for="source_provider_id">Source provider</Label>
|
||||||
<select
|
<Select v-model="form.source_provider_id">
|
||||||
id="source_provider_id"
|
<SelectTrigger id="source_provider_id">
|
||||||
v-model="form.source_provider_id"
|
<SelectValue placeholder="No provider" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">No provider</option>
|
<SelectItem
|
||||||
<option
|
v-for="provider in sourceProviders"
|
||||||
v-for="provider in sourceProviders"
|
:key="provider.id"
|
||||||
:key="provider.id"
|
:value="String(provider.id)"
|
||||||
:value="provider.id"
|
>
|
||||||
>
|
{{ provider.name }} · {{ provider.type }}
|
||||||
{{ provider.name }} · {{ provider.type }}
|
</SelectItem>
|
||||||
</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="form.errors.source_provider_id" />
|
<InputError :message="form.errors.source_provider_id" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card v-for="application in applications" :key="application.id" class="relative w-full">
|
||||||
v-for="application in applications"
|
|
||||||
:key="application.id"
|
|
||||||
class="relative w-full"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ application.name }}</CardTitle>
|
<CardTitle>{{ application.name }}</CardTitle>
|
||||||
<CardDescription
|
<CardDescription
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ defineProps<{
|
|||||||
<CardTitle>Repository Deploy Key</CardTitle>
|
<CardTitle>Repository Deploy Key</CardTitle>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="
|
:variant="
|
||||||
application.deploy_key_installed_at ? 'success' : 'secondary'
|
application.deploy_key_installed_at
|
||||||
|
? 'success'
|
||||||
|
: 'secondary'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
@@ -140,8 +142,8 @@ defineProps<{
|
|||||||
<CardTitle>Registry required before deployment</CardTitle>
|
<CardTitle>Registry required before deployment</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This organisation has {{ deploymentRequirements.serverCount }}
|
This organisation has {{ deploymentRequirements.serverCount }}
|
||||||
servers and no registry. Multi-server deployments need a registry
|
servers and no registry. Multi-server deployments need a registry so
|
||||||
so every server can pull the same build artifact.
|
every server can pull the same build artifact.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -310,7 +312,8 @@ defineProps<{
|
|||||||
@click="
|
@click="
|
||||||
router.post(
|
router.post(
|
||||||
route('environment-migrations.store', {
|
route('environment-migrations.store', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation:
|
||||||
|
$page.props.organisation.id,
|
||||||
application: application.id,
|
application: application.id,
|
||||||
environment: environment.id,
|
environment: environment.id,
|
||||||
}),
|
}),
|
||||||
@@ -335,7 +338,8 @@ defineProps<{
|
|||||||
@click="
|
@click="
|
||||||
router.post(
|
router.post(
|
||||||
route('environment-workers.store', {
|
route('environment-workers.store', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation:
|
||||||
|
$page.props.organisation.id,
|
||||||
application: application.id,
|
application: application.id,
|
||||||
environment: environment.id,
|
environment: environment.id,
|
||||||
}),
|
}),
|
||||||
@@ -372,7 +376,9 @@ defineProps<{
|
|||||||
>
|
>
|
||||||
<Badge variant="outline">{{ artifact.status }}</Badge>
|
<Badge variant="outline">{{ artifact.status }}</Badge>
|
||||||
<span>{{ artifact.commit_sha }}</span>
|
<span>{{ artifact.commit_sha }}</span>
|
||||||
<span v-if="artifact.image_digest">{{ artifact.image_digest }}</span>
|
<span v-if="artifact.image_digest">{{
|
||||||
|
artifact.image_digest
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ defineProps<{
|
|||||||
<CardTitle>Metadata</CardTitle>
|
<CardTitle>Metadata</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(artifact.metadata ?? {}, null, 2) }}</pre>
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
|
||||||
|
JSON.stringify(artifact.metadata ?? {}, null, 2)
|
||||||
|
}}</pre>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
import { computed, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
@@ -16,7 +23,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
service_id: props.services[0]?.id ?? null,
|
service_id: String(props.services[0]?.id ?? ""),
|
||||||
role: "database",
|
role: "database",
|
||||||
name: "",
|
name: "",
|
||||||
env_prefix: "",
|
env_prefix: "",
|
||||||
@@ -35,7 +42,7 @@ const compatibleServices = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedService = computed(() =>
|
const selectedService = computed(() =>
|
||||||
props.services.find((service) => service.id === form.service_id),
|
props.services.find((service) => String(service.id) === form.service_id),
|
||||||
);
|
);
|
||||||
const generatedSliceType = computed(() => {
|
const generatedSliceType = computed(() => {
|
||||||
if (selectedService.value?.type === "postgres") {
|
if (selectedService.value?.type === "postgres") {
|
||||||
@@ -133,19 +140,20 @@ watch(
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="service_id">Service</Label>
|
<Label for="service_id">Service</Label>
|
||||||
<select
|
<Select v-model="form.service_id">
|
||||||
id="service_id"
|
<SelectTrigger id="service_id">
|
||||||
v-model="form.service_id"
|
<SelectValue placeholder="Select a service" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option
|
<SelectItem
|
||||||
v-for="service in compatibleServices"
|
v-for="service in compatibleServices"
|
||||||
:key="service.id"
|
:key="service.id"
|
||||||
:value="service.id"
|
:value="String(service.id)"
|
||||||
>
|
>
|
||||||
{{ service.name }} · {{ service.type }}
|
{{ service.name }} · {{ service.type }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.service_id" />
|
<InputError :message="form.errors.service_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,15 +180,16 @@ watch(
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="role">Role</Label>
|
<Label for="role">Role</Label>
|
||||||
<select
|
<Select v-model="form.role">
|
||||||
id="role"
|
<SelectTrigger id="role">
|
||||||
v-model="form.role"
|
<SelectValue placeholder="Select a role" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="role in roles" :key="role" :value="role">
|
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||||
{{ role.replace("_", " ") }}
|
{{ role.replace("_", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.role" />
|
<InputError :message="form.errors.role" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,15 +217,28 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3 md:grid-cols-3">
|
<div
|
||||||
|
v-if="form.role === 'gateway'"
|
||||||
|
class="grid gap-4 rounded-md border p-3 md:grid-cols-3"
|
||||||
|
>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="domain">Domain</Label>
|
<Label for="domain">Domain</Label>
|
||||||
<Input id="domain" v-model="form.domain" type="text" placeholder="app.example.com" />
|
<Input
|
||||||
|
id="domain"
|
||||||
|
v-model="form.domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="app.example.com"
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.domain" />
|
<InputError :message="form.errors.domain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="path_prefix">Path prefix</Label>
|
<Label for="path_prefix">Path prefix</Label>
|
||||||
<Input id="path_prefix" v-model="form.path_prefix" type="text" placeholder="/" />
|
<Input
|
||||||
|
id="path_prefix"
|
||||||
|
v-model="form.path_prefix"
|
||||||
|
type="text"
|
||||||
|
placeholder="/"
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.path_prefix" />
|
<InputError :message="form.errors.path_prefix" />
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 pt-7 text-sm">
|
<label class="flex items-center gap-2 pt-7 text-sm">
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -78,15 +85,16 @@ const detach = (): void => {
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="role">Role</Label>
|
<Label for="role">Role</Label>
|
||||||
<select
|
<Select v-model="form.role">
|
||||||
id="role"
|
<SelectTrigger id="role">
|
||||||
v-model="form.role"
|
<SelectValue placeholder="Select a role" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="role in roles" :key="role" :value="role">
|
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||||
{{ role.replace("_", " ") }}
|
{{ role.replace("_", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.role" />
|
<InputError :message="form.errors.role" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,10 @@ const destroyVariable = (variable: Record<string, any>): void => {
|
|||||||
>
|
>
|
||||||
{{ variable.key }}
|
{{ variable.key }}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
|
<Badge
|
||||||
{{ variable.source.replace('_', ' ') }}
|
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
|
||||||
|
>
|
||||||
|
{{ variable.source.replace("_", " ") }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="!variable.overridable" variant="outline">locked</Badge>
|
<Badge v-if="!variable.overridable" variant="outline">locked</Badge>
|
||||||
<Badge variant="outline">secret</Badge>
|
<Badge variant="outline">secret</Badge>
|
||||||
@@ -159,7 +161,11 @@ const destroyVariable = (variable: Record<string, any>): void => {
|
|||||||
<Button
|
<Button
|
||||||
size="iconxs"
|
size="iconxs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:aria-label="isRevealed(variable) ? `Hide ${variable.key}` : `Reveal ${variable.key}`"
|
:aria-label="
|
||||||
|
isRevealed(variable)
|
||||||
|
? `Hide ${variable.key}`
|
||||||
|
: `Reveal ${variable.key}`
|
||||||
|
"
|
||||||
@click="toggleReveal(variable)"
|
@click="toggleReveal(variable)"
|
||||||
>
|
>
|
||||||
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" />
|
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" />
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -123,33 +130,38 @@ const destroyEnvironment = (): void => {
|
|||||||
</label>
|
</label>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="scheduler_target_service_id">Target service</Label>
|
<Label for="scheduler_target_service_id">Target service</Label>
|
||||||
<select
|
<Select v-model="form.scheduler_target_service_id">
|
||||||
id="scheduler_target_service_id"
|
<SelectTrigger id="scheduler_target_service_id">
|
||||||
v-model="form.scheduler_target_service_id"
|
<SelectValue placeholder="No target" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">No target</option>
|
<SelectItem
|
||||||
<option
|
v-for="service in environment.services"
|
||||||
v-for="service in environment.services"
|
:key="service.id"
|
||||||
:key="service.id"
|
:value="String(service.id)"
|
||||||
:value="service.id"
|
>
|
||||||
>
|
{{ service.name }}
|
||||||
{{ service.name }}
|
</SelectItem>
|
||||||
</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="form.errors.scheduler_target_service_id" />
|
<InputError :message="form.errors.scheduler_target_service_id" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="scheduler_mode">Mode</Label>
|
<Label for="scheduler_mode">Mode</Label>
|
||||||
<select
|
<Select v-model="form.scheduler_mode">
|
||||||
id="scheduler_mode"
|
<SelectTrigger id="scheduler_mode">
|
||||||
v-model="form.scheduler_mode"
|
<SelectValue placeholder="Select mode" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="mode in schedulerModes" :key="mode" :value="mode">
|
<SelectItem
|
||||||
{{ mode.replace("_", " ") }}
|
v-for="mode in schedulerModes"
|
||||||
</option>
|
:key="mode"
|
||||||
</select>
|
:value="mode"
|
||||||
|
>
|
||||||
|
{{ mode.replace("_", " ") }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.scheduler_mode" />
|
<InputError :message="form.errors.scheduler_mode" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -158,20 +170,27 @@ const destroyEnvironment = (): void => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Build & Health</CardTitle>
|
<CardTitle>Build & Health</CardTitle>
|
||||||
<CardDescription>Defaults used by deploy planning and runtime checks.</CardDescription>
|
<CardDescription
|
||||||
|
>Defaults used by deploy planning and runtime checks.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="build_strategy">Build strategy</Label>
|
<Label for="build_strategy">Build strategy</Label>
|
||||||
<select
|
<Select v-model="form.build_strategy">
|
||||||
id="build_strategy"
|
<SelectTrigger id="build_strategy">
|
||||||
v-model="form.build_strategy"
|
<SelectValue placeholder="Select strategy" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="strategy in buildStrategies" :key="strategy" :value="strategy">
|
<SelectItem
|
||||||
{{ strategy.replace("_", " ") }}
|
v-for="strategy in buildStrategies"
|
||||||
</option>
|
:key="strategy"
|
||||||
</select>
|
:value="strategy"
|
||||||
|
>
|
||||||
|
{{ strategy.replace("_", " ") }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.build_strategy" />
|
<InputError :message="form.errors.build_strategy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
:href="route('applications.create', { organisation: $page.props.organisation.id })"
|
:href="
|
||||||
|
route('applications.create', { organisation: $page.props.organisation.id })
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Application
|
Application
|
||||||
@@ -63,13 +65,17 @@ defineProps<{
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<BoxesIcon class="size-4" />
|
<BoxesIcon class="size-4" />
|
||||||
<span class="font-medium">{{ environment.name }}</span>
|
<span class="font-medium">{{ environment.name }}</span>
|
||||||
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">
|
<Badge
|
||||||
{{ environment.status.replace('-', ' ') }}
|
:variant="
|
||||||
|
environment.status === 'active' ? 'success' : 'secondary'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ environment.status.replace("-", " ") }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
{{ environment.branch }} · {{ environment.services_count }} services ·
|
{{ environment.branch }} · {{ environment.services_count }} services
|
||||||
{{ environment.build_artifacts_count }} builds
|
· {{ environment.build_artifacts_count }} builds
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
import SpecRow from "@/components/environments/SpecRow.vue";
|
||||||
|
import TopologyCard from "@/components/environments/TopologyCard.vue";
|
||||||
import InputError from "@/components/InputError.vue";
|
import InputError from "@/components/InputError.vue";
|
||||||
|
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
|
||||||
|
import StatusIndicator from "@/components/StatusIndicator.vue";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
|
BoxIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayersIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
ZapIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
@@ -38,13 +48,24 @@ const gatewayAttachments = computed(() =>
|
|||||||
props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
|
props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const gatewayCutovers = computed(() =>
|
const resourceAttachments = computed(() =>
|
||||||
props.environment.operations.filter((operation) => operation.kind === "gateway_cutover"),
|
props.environment.attachments.filter((attachment) => attachment.role !== "gateway"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const attachmentIcons: Record<string, object | Function> = {
|
||||||
|
database: DatabaseIcon,
|
||||||
|
cache: ZapIcon,
|
||||||
|
queue: ListChecksIcon,
|
||||||
|
storage: BoxIcon,
|
||||||
|
gateway: GlobeIcon,
|
||||||
|
custom: ServerIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachmentIcon = (role: string): object | Function => attachmentIcons[role] ?? ServerIcon;
|
||||||
|
|
||||||
const caddyfilePreviewFor = (attachmentId: number): string =>
|
const caddyfilePreviewFor = (attachmentId: number): string =>
|
||||||
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)?.caddyfile ??
|
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)
|
||||||
"# No route preview available";
|
?.caddyfile ?? "# No route preview available";
|
||||||
|
|
||||||
const deployForm = useForm({
|
const deployForm = useForm({
|
||||||
target_commit: "",
|
target_commit: "",
|
||||||
@@ -83,37 +104,40 @@ const deployEnvironment = (): void => {
|
|||||||
{ title: environment.name },
|
{ title: environment.name },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
<div class="mx-auto w-full max-w-7xl space-y-6 p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<!-- Header -->
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">{{ environment.name }}</h2>
|
<h2 class="text-2xl font-bold tracking-tight">{{ environment.name }}</h2>
|
||||||
<Badge
|
<StatusIndicator :status="environment.status" size="md" />
|
||||||
:variant="environment.status === 'active' ? 'success' : 'secondary'"
|
</div>
|
||||||
>{{ environment.status.replace("-", " ") }}</Badge
|
<div
|
||||||
>
|
class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<GitBranchIcon class="size-4" />{{ environment.branch }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Scheduler:
|
||||||
|
{{
|
||||||
|
environment.scheduler_enabled
|
||||||
|
? `${environment.scheduler_mode} on ${
|
||||||
|
environment.services?.find(
|
||||||
|
(service) =>
|
||||||
|
service.id ===
|
||||||
|
environment.scheduler_target_service_id,
|
||||||
|
)?.name ?? "selected service"
|
||||||
|
}`
|
||||||
|
: "disabled"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
:as="Link"
|
||||||
:href="
|
:href="
|
||||||
route('environments.edit', {
|
route('environments.edit', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
@@ -141,8 +165,8 @@ const deployEnvironment = (): void => {
|
|||||||
Migrate
|
Migrate
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
:as="Link"
|
||||||
:href="
|
:href="
|
||||||
route('environment-attachments.create', {
|
route('environment-attachments.create', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
@@ -154,48 +178,27 @@ const deployEnvironment = (): void => {
|
|||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Attach
|
Attach
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
|
||||||
|
:title="
|
||||||
|
deploymentRequirements.registryRequired
|
||||||
|
? 'Configure a registry before deploying to multiple servers.'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@click="deployEnvironment"
|
||||||
|
>
|
||||||
|
<RocketIcon class="size-4" />
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card
|
||||||
|
v-if="deploymentRequirements.registryRequired"
|
||||||
|
class="border-amber-300 bg-amber-50 dark:bg-amber-950/30"
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Deploy Target</CardTitle>
|
<CardTitle>Registry required</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>
|
<CardDescription>
|
||||||
This environment spans {{ deploymentRequirements.serverCount }} servers.
|
This environment spans {{ deploymentRequirements.serverCount }} servers.
|
||||||
Configure a registry before deploying so every server can pull the same
|
Configure a registry before deploying so every server can pull the same
|
||||||
@@ -217,275 +220,179 @@ const deployEnvironment = (): void => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
<!-- Topology -->
|
||||||
<div class="space-y-4">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<Card>
|
<!-- Network -->
|
||||||
<CardHeader>
|
<div class="space-y-3">
|
||||||
<CardTitle>Services</CardTitle>
|
<h3 class="px-1 text-sm font-medium text-muted-foreground">Network</h3>
|
||||||
<CardDescription
|
|
||||||
>{{ environment.services?.length ?? 0 }} runtime and managed
|
|
||||||
services</CardDescription
|
|
||||||
>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="grid gap-3">
|
|
||||||
<div
|
|
||||||
v-for="service in environment.services"
|
|
||||||
:key="service.id"
|
|
||||||
class="rounded-md border p-3"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ServerIcon class="size-4" />
|
|
||||||
<h3 class="font-semibold">{{ service.name }}</h3>
|
|
||||||
<Badge variant="outline">{{ service.type }}</Badge>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
{{ service.replicas?.length ?? 0 }} replicas ·
|
|
||||||
{{ service.slices?.length ?? 0 }} slices ·
|
|
||||||
{{ service.status?.replace("-", " ") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:as="Link"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
:href="
|
|
||||||
route('environment-services.show', {
|
|
||||||
organisation: $page.props.organisation.id,
|
|
||||||
application: environment.application_id,
|
|
||||||
environment: environment.id,
|
|
||||||
service: service.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<SettingsIcon class="size-4" />
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<TopologyCard
|
||||||
<CardHeader>
|
v-for="attachment in gatewayAttachments"
|
||||||
<CardTitle>Operations</CardTitle>
|
:key="attachment.id"
|
||||||
</CardHeader>
|
:icon="GlobeIcon"
|
||||||
<CardContent>
|
title="Gateway"
|
||||||
<OperationTimeline :operations="environment.operations" />
|
:subtitle="attachment.service_slice?.config?.domain ?? 'Unassigned domain'"
|
||||||
</CardContent>
|
:status="attachment.service_slice?.config?.certificate_status ?? 'pending'"
|
||||||
</Card>
|
>
|
||||||
|
<SpecRow
|
||||||
<Card>
|
:icon="GlobeIcon"
|
||||||
<CardHeader>
|
label="Domain"
|
||||||
<div class="flex items-center justify-between gap-3">
|
:value="attachment.service_slice?.config?.domain ?? 'not set'"
|
||||||
<div>
|
/>
|
||||||
<CardTitle>Builds</CardTitle>
|
<SpecRow
|
||||||
<CardDescription>
|
:icon="ServerIcon"
|
||||||
Recent artifacts planned or built for this environment.
|
label="Path"
|
||||||
</CardDescription>
|
:value="attachment.service_slice?.config?.path_prefix ?? '/'"
|
||||||
</div>
|
/>
|
||||||
<Button
|
<SpecRow :icon="ShieldCheckIcon" label="TLS">
|
||||||
:as="Link"
|
<StatusIndicator
|
||||||
size="sm"
|
:status="
|
||||||
variant="secondary"
|
attachment.service_slice?.config?.tls_enabled === false
|
||||||
:href="
|
? 'disabled'
|
||||||
route('build-artifacts.index', {
|
: 'enabled'
|
||||||
organisation: $page.props.organisation.id,
|
"
|
||||||
application: application.id,
|
/>
|
||||||
environment: environment.id,
|
</SpecRow>
|
||||||
})
|
<Collapsible>
|
||||||
"
|
<CollapsibleTrigger
|
||||||
>
|
class="flex w-full items-center justify-between pt-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
View all
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="grid gap-2">
|
|
||||||
<div
|
|
||||||
v-for="artifact in environment.build_artifacts"
|
|
||||||
:key="artifact.id"
|
|
||||||
class="rounded-md border p-3 text-sm"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
View Caddyfile
|
||||||
<Badge variant="outline">{{ artifact.status }}</Badge>
|
<ChevronDownIcon class="size-3.5" />
|
||||||
<span class="font-medium">{{ artifact.commit_sha }}</span>
|
</CollapsibleTrigger>
|
||||||
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
|
<CollapsibleContent>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Attachments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="grid gap-2">
|
|
||||||
<div
|
|
||||||
v-for="attachment in environment.attachments"
|
|
||||||
:key="attachment.id"
|
|
||||||
class="rounded-md border p-3 text-sm"
|
|
||||||
>
|
|
||||||
<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">{{
|
<pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
|
||||||
caddyfilePreviewFor(attachment.id)
|
caddyfilePreviewFor(attachment.id)
|
||||||
}}</pre>
|
}}</pre>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
</CollapsibleContent>
|
||||||
<Badge variant="outline">Render route</Badge>
|
</Collapsible>
|
||||||
<Badge variant="outline">Health check</Badge>
|
</TopologyCard>
|
||||||
<Badge variant="outline">Reload gateway</Badge>
|
|
||||||
<Badge variant="outline">Drain old upstream</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<OperationTimeline :operations="gatewayCutovers" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card v-else>
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Gateway Routes</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
No gateway routes are configured for this environment.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:as="Link"
|
|
||||||
size="xs"
|
|
||||||
variant="secondary"
|
|
||||||
:href="
|
|
||||||
route('gateway.routes.index', {
|
|
||||||
organisation: $page.props.organisation.id,
|
|
||||||
application: application.id,
|
|
||||||
environment: environment.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Manage routes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card v-if="gatewayAttachments.length === 0" class="border-dashed">
|
||||||
<CardHeader>
|
<CardContent class="space-y-3 p-5 text-center">
|
||||||
<CardTitle>Variables</CardTitle>
|
<p class="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
No gateway routes configured.
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
</p>
|
||||||
<Badge
|
|
||||||
v-for="variable in environment.variables"
|
|
||||||
:key="variable.id"
|
|
||||||
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
|
|
||||||
>
|
|
||||||
{{ variable.key }} · {{ variable.source.replace("_", " ") }}
|
|
||||||
<span v-if="!variable.overridable"> · locked</span>
|
|
||||||
</Badge>
|
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:href="
|
:href="
|
||||||
route('environment-variables.index', {
|
route('gateway.routes.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Manage routes
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compute -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="px-1 text-sm font-medium text-muted-foreground">Compute</h3>
|
||||||
|
|
||||||
|
<TopologyCard
|
||||||
|
v-for="service in environment.services"
|
||||||
|
:key="service.id"
|
||||||
|
:icon="ServerIcon"
|
||||||
|
:title="service.name"
|
||||||
|
:subtitle="service.type"
|
||||||
|
:status="service.status"
|
||||||
|
>
|
||||||
|
<SpecRow
|
||||||
|
:icon="LayersIcon"
|
||||||
|
label="Replicas"
|
||||||
|
:value="service.replicas?.length ?? 0"
|
||||||
|
/>
|
||||||
|
<SpecRow
|
||||||
|
:icon="LayersIcon"
|
||||||
|
label="Slices"
|
||||||
|
:value="service.slices?.length ?? 0"
|
||||||
|
/>
|
||||||
|
<SpecRow
|
||||||
|
:icon="ServerIcon"
|
||||||
|
label="Deploy policy"
|
||||||
|
:value="service.deploy_policy ?? 'default'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
class="mt-1 w-full"
|
||||||
|
:href="
|
||||||
|
route('environment-services.show', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: environment.application_id,
|
||||||
|
environment: environment.id,
|
||||||
|
service: service.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
Open service
|
||||||
|
</Button>
|
||||||
|
</TopologyCard>
|
||||||
|
|
||||||
|
<Card v-if="(environment.services?.length ?? 0) === 0" class="border-dashed">
|
||||||
|
<CardContent class="p-5 text-center text-sm text-muted-foreground">
|
||||||
|
No services in this environment.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resources -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="px-1 text-sm font-medium text-muted-foreground">Resources</h3>
|
||||||
|
|
||||||
|
<TopologyCard
|
||||||
|
v-for="attachment in resourceAttachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
:icon="attachmentIcon(attachment.role)"
|
||||||
|
:title="attachment.role.replaceAll('_', ' ')"
|
||||||
|
:subtitle="attachment.service?.name"
|
||||||
|
>
|
||||||
|
<SpecRow
|
||||||
|
:icon="ServerIcon"
|
||||||
|
label="Service"
|
||||||
|
:value="attachment.service?.name ?? '—'"
|
||||||
|
/>
|
||||||
|
<SpecRow
|
||||||
|
:icon="LayersIcon"
|
||||||
|
label="Slice"
|
||||||
|
:value="attachment.service_slice?.name ?? 'service level'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
class="mt-1 w-full"
|
||||||
|
:href="
|
||||||
|
route('environment-attachments.edit', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
application: application.id,
|
||||||
|
environment: environment.id,
|
||||||
|
attachment: attachment.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
Edit attachment
|
||||||
|
</Button>
|
||||||
|
</TopologyCard>
|
||||||
|
|
||||||
|
<Card v-if="resourceAttachments.length === 0" class="border-dashed">
|
||||||
|
<CardContent class="space-y-3 p-5 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">No resources attached.</p>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:href="
|
||||||
|
route('environment-attachments.create', {
|
||||||
organisation: $page.props.organisation.id,
|
organisation: $page.props.organisation.id,
|
||||||
application: application.id,
|
application: application.id,
|
||||||
environment: environment.id,
|
environment: environment.id,
|
||||||
@@ -493,43 +400,152 @@ const deployEnvironment = (): void => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Manage
|
Attach resource
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Variables -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="pb-3">
|
||||||
<CardTitle>Service policy</CardTitle>
|
<div class="flex items-center justify-between">
|
||||||
<CardDescription>
|
<CardTitle class="text-base">Variables</CardTitle>
|
||||||
Migration and scheduler-related defaults exposed by current
|
<Button
|
||||||
services.
|
:as="Link"
|
||||||
</CardDescription>
|
size="xs"
|
||||||
</CardHeader>
|
variant="secondary"
|
||||||
<CardContent class="grid gap-2 text-sm">
|
:href="
|
||||||
<div
|
route('environment-variables.index', {
|
||||||
v-for="service in environment.services"
|
organisation: $page.props.organisation.id,
|
||||||
:key="service.id"
|
application: application.id,
|
||||||
class="rounded-md border p-3"
|
environment: environment.id,
|
||||||
>
|
})
|
||||||
<div class="font-medium">{{ service.name }}</div>
|
"
|
||||||
<div class="text-muted-foreground">
|
>
|
||||||
Deploy policy: {{ service.deploy_policy ?? "default" }} ·
|
Manage
|
||||||
Roles: {{ service.process_roles?.join(", ") || "none" }}
|
</Button>
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground">
|
|
||||||
Migration:
|
|
||||||
{{
|
|
||||||
service.config?.migration_mode ??
|
|
||||||
service.config?.migration_timing ??
|
|
||||||
"not configured"
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="variable in environment.variables"
|
||||||
|
:key="variable.id"
|
||||||
|
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
|
||||||
|
>
|
||||||
|
{{ variable.key }}
|
||||||
|
<span v-if="!variable.overridable"> · locked</span>
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="(environment.variables?.length ?? 0) === 0"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No variables set.
|
||||||
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deploy a specific commit -->
|
||||||
|
<Collapsible>
|
||||||
|
<Card>
|
||||||
|
<CollapsibleTrigger class="w-full">
|
||||||
|
<CardHeader class="flex-row items-center justify-between">
|
||||||
|
<div class="text-left">
|
||||||
|
<CardTitle class="text-base">Deploy a specific commit</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pin this deployment to a commit SHA instead of the branch head.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon class="size-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<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
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<RocketIcon class="size-4" />
|
||||||
|
Deploy commit
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<!-- Activity -->
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Operations</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OperationTimeline :operations="environment.operations" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<CardTitle class="text-base">Builds</CardTitle>
|
||||||
|
<Button
|
||||||
|
:as="Link"
|
||||||
|
size="xs"
|
||||||
|
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="artifact in environment.build_artifacts"
|
||||||
|
:key="artifact.id"
|
||||||
|
class="rounded-md border p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusIndicator :status="artifact.status" />
|
||||||
|
<span class="font-mono text-xs">{{ artifact.commit_sha }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{{ artifact.registry_ref ?? "No registry ref" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(environment.build_artifacts?.length ?? 0) === 0"
|
||||||
|
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No builds recorded.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -13,7 +20,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
service_id: props.services[0]?.id ?? null,
|
service_id: String(props.services[0]?.id ?? ""),
|
||||||
name: "",
|
name: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
path_prefix: "/",
|
path_prefix: "/",
|
||||||
@@ -77,16 +84,20 @@ const form = useForm({
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="service_id">Gateway service</Label>
|
<Label for="service_id">Gateway service</Label>
|
||||||
<select
|
<Select v-model="form.service_id" required>
|
||||||
id="service_id"
|
<SelectTrigger id="service_id">
|
||||||
v-model="form.service_id"
|
<SelectValue placeholder="Select gateway service" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
required
|
<SelectContent>
|
||||||
>
|
<SelectItem
|
||||||
<option v-for="service in services" :key="service.id" :value="service.id">
|
v-for="service in services"
|
||||||
{{ service.name }}
|
:key="service.id"
|
||||||
</option>
|
:value="String(service.id)"
|
||||||
</select>
|
>
|
||||||
|
{{ service.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.service_id" />
|
<InputError :message="form.errors.service_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +110,12 @@ const form = useForm({
|
|||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="domain">Domain</Label>
|
<Label for="domain">Domain</Label>
|
||||||
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
|
<Input
|
||||||
|
id="domain"
|
||||||
|
v-model="form.domain"
|
||||||
|
placeholder="app.example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.domain" />
|
<InputError :message="form.errors.domain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ const destroyRoute = (): void => {
|
|||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="domain">Domain</Label>
|
<Label for="domain">Domain</Label>
|
||||||
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
|
<Input
|
||||||
|
id="domain"
|
||||||
|
v-model="form.domain"
|
||||||
|
placeholder="app.example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.domain" />
|
<InputError :message="form.errors.domain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -111,7 +116,11 @@ const destroyRoute = (): void => {
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="certificate_status">Certificate status</Label>
|
<Label for="certificate_status">Certificate status</Label>
|
||||||
<Input id="certificate_status" v-model="form.certificate_status" placeholder="pending" />
|
<Input
|
||||||
|
id="certificate_status"
|
||||||
|
v-model="form.certificate_status"
|
||||||
|
placeholder="pending"
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.certificate_status" />
|
<InputError :message="form.errors.certificate_status" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const destroyRoute = (routeAttachment: Record<string, any>): void => {
|
const destroyRoute = (routeAttachment: Record<string, any>): void => {
|
||||||
const domain = routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name;
|
const domain =
|
||||||
|
routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name;
|
||||||
|
|
||||||
if (!window.confirm(`Remove gateway route ${domain}?`)) {
|
if (!window.confirm(`Remove gateway route ${domain}?`)) {
|
||||||
return;
|
return;
|
||||||
@@ -87,7 +88,10 @@ const destroyRoute = (routeAttachment: Record<string, any>): void => {
|
|||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{{ routeAttachment.service_slice?.config?.domain ?? "Unassigned domain" }}
|
{{
|
||||||
|
routeAttachment.service_slice?.config?.domain ??
|
||||||
|
"Unassigned domain"
|
||||||
|
}}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{{ routeAttachment.service?.name }} ·
|
{{ routeAttachment.service?.name }} ·
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const setFilter = (key: string, value: string | null): void => {
|
|||||||
:variant="filters.kind === kind ? 'default' : 'secondary'"
|
:variant="filters.kind === kind ? 'default' : 'secondary'"
|
||||||
@click="setFilter('kind', kind)"
|
@click="setFilter('kind', kind)"
|
||||||
>
|
>
|
||||||
{{ kind.replace('_', ' ') }}
|
{{ kind.replace("_", " ") }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-for="status in operationStatuses"
|
v-for="status in operationStatuses"
|
||||||
@@ -74,7 +74,7 @@ const setFilter = (key: string, value: string | null): void => {
|
|||||||
:variant="filters.status === status ? 'default' : 'outline'"
|
:variant="filters.status === status ? 'default' : 'outline'"
|
||||||
@click="setFilter('status', filters.status === status ? null : status)"
|
@click="setFilter('status', filters.status === status ? null : status)"
|
||||||
>
|
>
|
||||||
{{ status.replace('-', ' ') }}
|
{{ status.replace("-", " ") }}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ defineProps<{
|
|||||||
operation: Record<string, any>;
|
operation: Record<string, any>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
const label = (value?: string | null): string =>
|
||||||
|
value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
|
||||||
|
|
||||||
usePoll(5000, {}, { keepAlive: true });
|
usePoll(5000, {}, { keepAlive: true });
|
||||||
|
|
||||||
@@ -49,9 +50,13 @@ const cancelOperation = (operation: Record<string, any>): void => {
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<h2 class="text-3xl font-bold tracking-tight">{{ label(operation.kind) }}</h2>
|
<h2 class="text-3xl font-bold tracking-tight">
|
||||||
|
{{ label(operation.kind) }}
|
||||||
|
</h2>
|
||||||
<Badge variant="outline">{{ operation.hash }}</Badge>
|
<Badge variant="outline">{{ operation.hash }}</Badge>
|
||||||
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">
|
<Badge
|
||||||
|
:variant="operation.status === 'completed' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
{{ label(operation.status) }}
|
{{ label(operation.status) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
import { Trash2Icon } from "lucide-vue-next";
|
import { Trash2Icon } from "lucide-vue-next";
|
||||||
@@ -93,7 +100,8 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Invite Member</CardTitle>
|
<CardTitle>Invite Member</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Existing users are added immediately. New emails remain pending until accepted.
|
Existing users are added immediately. New emails remain pending until
|
||||||
|
accepted.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -104,7 +112,10 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
|
|||||||
route('organisation-members.store', {
|
route('organisation-members.store', {
|
||||||
organisation: organisation.id,
|
organisation: organisation.id,
|
||||||
}),
|
}),
|
||||||
{ preserveScroll: true, onSuccess: () => inviteForm.reset('email') },
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => inviteForm.reset('email'),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -115,15 +126,16 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="role">Role</Label>
|
<Label for="role">Role</Label>
|
||||||
<select
|
<Select v-model="inviteForm.role">
|
||||||
id="role"
|
<SelectTrigger id="role">
|
||||||
v-model="inviteForm.role"
|
<SelectValue placeholder="Select a role" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="role in roles" :key="role" :value="role">
|
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||||
{{ role }}
|
{{ role }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="inviteForm.errors.role" />
|
<InputError :message="inviteForm.errors.role" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
@@ -151,23 +163,24 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
|
|||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
Invited by
|
Invited by
|
||||||
{{ invitation.invited_by?.name ?? "Keystone" }}
|
{{ invitation.invited_by?.name ?? "Keystone" }}
|
||||||
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span>
|
<span v-if="invitation.expires_at">
|
||||||
|
· expires {{ invitation.expires_at }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
:model-value="invitation.role ?? 'member'"
|
||||||
:value="invitation.role ?? 'member'"
|
@update:model-value="updateInvitationRole(invitation, $event as string)"
|
||||||
@change="
|
|
||||||
updateInvitationRole(
|
|
||||||
invitation,
|
|
||||||
($event.target as HTMLSelectElement).value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<option v-for="role in roles" :key="role" :value="role">
|
<SelectTrigger>
|
||||||
{{ role }}
|
<SelectValue placeholder="Select a role" />
|
||||||
</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
size="iconxs"
|
size="iconxs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -201,15 +214,19 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
|
|||||||
<div class="font-medium">{{ member.name }}</div>
|
<div class="font-medium">{{ member.name }}</div>
|
||||||
<div class="text-muted-foreground">{{ member.email }}</div>
|
<div class="text-muted-foreground">{{ member.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
:model-value="member.membership?.role ?? 'member'"
|
||||||
:value="member.membership?.role ?? 'member'"
|
@update:model-value="updateRole(member, $event as string)"
|
||||||
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
>
|
||||||
<option v-for="role in roles" :key="role" :value="role">
|
<SelectTrigger>
|
||||||
{{ role }}
|
<SelectValue placeholder="Select a role" />
|
||||||
</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||||
|
{{ role }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
size="iconxs"
|
size="iconxs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -153,20 +153,30 @@ const destroyResource = (url: string, label: string): void => {
|
|||||||
<Card class="mt-4">
|
<Card class="mt-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Health</CardTitle>
|
<CardTitle>Health</CardTitle>
|
||||||
<CardDescription>Aggregate signals across this organisation.</CardDescription>
|
<CardDescription
|
||||||
|
>Aggregate signals across this organisation.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-3 md:grid-cols-3">
|
<CardContent class="grid gap-3 md:grid-cols-3">
|
||||||
<div class="rounded-md border p-3">
|
<div class="rounded-md border p-3">
|
||||||
<div class="text-2xl font-semibold">{{ health.unhealthy_services }}</div>
|
<div class="text-2xl font-semibold">
|
||||||
|
{{ health.unhealthy_services }}
|
||||||
|
</div>
|
||||||
<div class="text-sm text-muted-foreground">Unhealthy services</div>
|
<div class="text-sm text-muted-foreground">Unhealthy services</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border p-3">
|
<div class="rounded-md border p-3">
|
||||||
<div class="text-2xl font-semibold">{{ health.failed_operations }}</div>
|
<div class="text-2xl font-semibold">
|
||||||
|
{{ health.failed_operations }}
|
||||||
|
</div>
|
||||||
<div class="text-sm text-muted-foreground">Failed operations</div>
|
<div class="text-sm text-muted-foreground">Failed operations</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border p-3">
|
<div class="rounded-md border p-3">
|
||||||
<div class="text-2xl font-semibold">{{ health.locked_variables }}</div>
|
<div class="text-2xl font-semibold">
|
||||||
<div class="text-sm text-muted-foreground">Environments with locked variables</div>
|
{{ health.locked_variables }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Environments with locked variables
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -51,15 +58,16 @@ const form = useForm({
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="type">Type</Label>
|
<Label for="type">Type</Label>
|
||||||
<select
|
<Select v-model="form.type">
|
||||||
id="type"
|
<SelectTrigger id="type">
|
||||||
v-model="form.type"
|
<SelectValue placeholder="Select type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="type in providerTypes" :key="type" :value="type">
|
<SelectItem v-for="type in providerTypes" :key="type" :value="type">
|
||||||
{{ type.replace("-", " ") }}
|
{{ type.replace("-", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.type" />
|
<InputError :message="form.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -53,19 +60,20 @@ const form = useForm({
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="type">Type</Label>
|
<Label for="type">Type</Label>
|
||||||
<select
|
<Select v-model="form.type">
|
||||||
id="type"
|
<SelectTrigger id="type">
|
||||||
v-model="form.type"
|
<SelectValue placeholder="Select type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option
|
<SelectItem
|
||||||
v-for="registryType in registryTypes"
|
v-for="registryType in registryTypes"
|
||||||
:key="registryType"
|
:key="registryType"
|
||||||
:value="registryType"
|
:value="registryType"
|
||||||
>
|
>
|
||||||
{{ registryType.replace("_", " ") }}
|
{{ registryType.replace("_", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.type" />
|
<InputError :message="form.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -71,15 +78,20 @@ const destroyRegistry = (): void => {
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="type">Type</Label>
|
<Label for="type">Type</Label>
|
||||||
<select
|
<Select v-model="form.type">
|
||||||
id="type"
|
<SelectTrigger id="type">
|
||||||
v-model="form.type"
|
<SelectValue placeholder="Select type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType">
|
<SelectItem
|
||||||
{{ registryType.replace("_", " ") }}
|
v-for="registryType in registryTypes"
|
||||||
</option>
|
:key="registryType"
|
||||||
</select>
|
:value="registryType"
|
||||||
|
>
|
||||||
|
{{ registryType.replace("_", " ") }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.type" />
|
<InputError :message="form.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
:as="Link"
|
:as="Link"
|
||||||
:href="route('registries.create', { organisation: $page.props.organisation.id })"
|
:href="
|
||||||
|
route('registries.create', { organisation: $page.props.organisation.id })
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Add registry
|
Add registry
|
||||||
@@ -67,7 +69,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-medium">{{ registry.name }}</span>
|
<span class="font-medium">{{ registry.name }}</span>
|
||||||
<Badge variant="outline">{{ registry.type?.replace("_", " ") }}</Badge>
|
<Badge variant="outline">{{
|
||||||
|
registry.type?.replace("_", " ")
|
||||||
|
}}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-muted-foreground">
|
<div class="mt-1 text-muted-foreground">
|
||||||
{{ registry.url ?? "No registry URL configured" }}
|
{{ registry.url ?? "No registry URL configured" }}
|
||||||
@@ -100,7 +104,11 @@ const destroyRegistry = (registry: Record<string, any>): void => {
|
|||||||
>
|
>
|
||||||
<PencilIcon class="size-3" />
|
<PencilIcon class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="iconxs" variant="ghost" @click="destroyRegistry(registry)">
|
<Button
|
||||||
|
size="iconxs"
|
||||||
|
variant="ghost"
|
||||||
|
@click="destroyRegistry(registry)"
|
||||||
|
>
|
||||||
<Trash2Icon class="size-3" />
|
<Trash2Icon class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,11 +47,7 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card v-for="server in servers.data" :key="server.id" class="relative w-full">
|
||||||
v-for="server in servers.data"
|
|
||||||
:key="server.id"
|
|
||||||
class="relative w-full"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ server.name }}</CardTitle>
|
<CardTitle>{{ server.name }}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -151,7 +147,8 @@ defineProps<{
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>No private networks</CardTitle>
|
<CardTitle>No private networks</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Networks are created when the first server is provisioned for a provider zone.
|
Networks are created when the first server is provisioned for a provider
|
||||||
|
zone.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
||||||
import { useCycleList, useInterval } from "@vueuse/core";
|
import { useCycleList, useInterval } from "@vueuse/core";
|
||||||
@@ -204,7 +211,10 @@ const healServer = (): void => {
|
|||||||
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
|
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-4">
|
<CardContent class="py-4">
|
||||||
<OperationTimeline :operations="server.service_operations" show-target />
|
<OperationTimeline
|
||||||
|
:operations="server.service_operations"
|
||||||
|
show-target
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,22 +222,29 @@ const healServer = (): void => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Firewall</CardTitle>
|
<CardTitle>Firewall</CardTitle>
|
||||||
<CardDescription>Rules Keystone knows about for this server.</CardDescription>
|
<CardDescription
|
||||||
|
>Rules Keystone knows about for this server.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4">
|
<CardContent class="grid gap-4">
|
||||||
<form class="grid gap-3 rounded-md border p-3" @submit.prevent="addFirewallRule">
|
<form
|
||||||
<div class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end">
|
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">
|
<div class="grid gap-2">
|
||||||
<Label for="firewall_type">Action</Label>
|
<Label for="firewall_type">Action</Label>
|
||||||
<select
|
<Select v-model="firewallForm.type" required>
|
||||||
id="firewall_type"
|
<SelectTrigger id="firewall_type">
|
||||||
v-model="firewallForm.type"
|
<SelectValue placeholder="Select an action" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
required
|
<SelectContent>
|
||||||
>
|
<SelectItem value="allow">allow</SelectItem>
|
||||||
<option value="allow">allow</option>
|
<SelectItem value="deny">deny</SelectItem>
|
||||||
<option value="deny">deny</option>
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
<InputError :message="firewallForm.errors.type" />
|
<InputError :message="firewallForm.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -261,7 +278,9 @@ const healServer = (): void => {
|
|||||||
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div>
|
<div class="font-medium">
|
||||||
|
{{ rule.type }} · {{ rule.ports }}
|
||||||
|
</div>
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
|
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
|
||||||
{{ rule.status }}
|
{{ rule.status }}
|
||||||
@@ -293,10 +312,13 @@ const healServer = (): void => {
|
|||||||
<div v-if="server.network">
|
<div v-if="server.network">
|
||||||
<div class="font-medium">{{ server.network.name }}</div>
|
<div class="font-medium">{{ server.network.name }}</div>
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
{{ server.network.ip_range }} · {{ server.network.network_zone }}
|
{{ server.network.ip_range }} ·
|
||||||
|
{{ server.network.network_zone }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-muted-foreground">No private network attached.</div>
|
<div v-else class="text-muted-foreground">
|
||||||
|
No private network attached.
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,7 +347,11 @@ const healServer = (): void => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button size="xs" disabled title="Services can be added after provisioning completes.">
|
<Button
|
||||||
|
size="xs"
|
||||||
|
disabled
|
||||||
|
title="Services can be added after provisioning completes."
|
||||||
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
Add service
|
Add service
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ const stopReplica = (): void => {
|
|||||||
{{ replica.container_name }}
|
{{ replica.container_name }}
|
||||||
</h2>
|
</h2>
|
||||||
<Badge variant="outline">{{ replica.status }}</Badge>
|
<Badge variant="outline">{{ replica.status }}</Badge>
|
||||||
<Badge :variant="replica.health_status === 'healthy' ? 'success' : 'secondary'">
|
<Badge
|
||||||
|
:variant="replica.health_status === 'healthy' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
{{ replica.health_status }}
|
{{ replica.health_status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -77,16 +84,20 @@ const form = useForm({
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="environment_id">Environment</Label>
|
<Label for="environment_id">Environment</Label>
|
||||||
<select
|
<Select v-model="form.environment_id">
|
||||||
id="environment_id"
|
<SelectTrigger id="environment_id">
|
||||||
v-model="form.environment_id"
|
<SelectValue placeholder="Service-level" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">Service-level</option>
|
<SelectItem
|
||||||
<option v-for="environment in environments" :key="environment.id" :value="environment.id">
|
v-for="environment in environments"
|
||||||
{{ environment.name }}
|
:key="environment.id"
|
||||||
</option>
|
:value="String(environment.id)"
|
||||||
</select>
|
>
|
||||||
|
{{ environment.name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.environment_id" />
|
<InputError :message="form.errors.environment_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,20 +86,30 @@ defineProps<{
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{{ slice.environment?.application?.name ?? "Service" }}
|
{{ slice.environment?.application?.name ?? "Service" }}
|
||||||
<span v-if="slice.environment">/ {{ slice.environment.name }}</span>
|
<span v-if="slice.environment"
|
||||||
|
>/ {{ slice.environment.name }}</span
|
||||||
|
>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline">{{ slice.type }}</Badge>
|
<Badge variant="outline">{{ slice.type }}</Badge>
|
||||||
<Badge :variant="slice.status === 'active' ? 'success' : 'secondary'">
|
<Badge
|
||||||
|
:variant="slice.status === 'active' ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
{{ slice.status }}
|
{{ slice.status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline">{{ slice.attachments.length }} attachments</Badge>
|
<Badge variant="outline"
|
||||||
|
>{{ slice.attachments.length }} attachments</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]">
|
<CardContent
|
||||||
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre>
|
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]"
|
||||||
|
>
|
||||||
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
|
||||||
|
JSON.stringify(slice.config ?? {}, null, 2)
|
||||||
|
}}</pre>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-2 text-sm font-medium">Recent operations</div>
|
<div class="mb-2 text-sm font-medium">Recent operations</div>
|
||||||
<OperationTimeline :operations="slice.operations" />
|
<OperationTimeline :operations="slice.operations" />
|
||||||
@@ -110,7 +120,10 @@ defineProps<{
|
|||||||
<Card v-if="slices.length === 0" class="border-dashed">
|
<Card v-if="slices.length === 0" class="border-dashed">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>No slices</CardTitle>
|
<CardTitle>No slices</CardTitle>
|
||||||
<CardDescription>Create a slice for a service-level capability or managed attachment.</CardDescription>
|
<CardDescription
|
||||||
|
>Create a slice for a service-level capability or managed
|
||||||
|
attachment.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ defineProps<{
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Configuration</CardTitle>
|
<CardTitle>Configuration</CardTitle>
|
||||||
<CardDescription>Credentials are stored encrypted and not revealed here.</CardDescription>
|
<CardDescription
|
||||||
|
>Credentials are stored encrypted and not revealed
|
||||||
|
here.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre>
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
|
||||||
|
JSON.stringify(slice.config ?? {}, null, 2)
|
||||||
|
}}</pre>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ defineProps<{
|
|||||||
"
|
"
|
||||||
class="rounded-md border p-3 text-sm hover:bg-muted/50"
|
class="rounded-md border p-3 text-sm hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<div class="font-medium">{{ attachment.role.replace('_', ' ') }}</div>
|
<div class="font-medium">{{ attachment.role.replace("_", " ") }}</div>
|
||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
{{ attachment.environment.name }} ·
|
{{ attachment.environment.name }} ·
|
||||||
{{ attachment.env_prefix ?? "default prefix" }}
|
{{ attachment.env_prefix ?? "default prefix" }}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -146,20 +153,27 @@ const destroyService = (): void => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Deployment</CardTitle>
|
<CardTitle>Deployment</CardTitle>
|
||||||
<CardDescription>Stateful services can track available image updates.</CardDescription>
|
<CardDescription
|
||||||
|
>Stateful services can track available image updates.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="deploy_policy">Deploy policy</Label>
|
<Label for="deploy_policy">Deploy policy</Label>
|
||||||
<select
|
<Select v-model="form.deploy_policy">
|
||||||
id="deploy_policy"
|
<SelectTrigger id="deploy_policy">
|
||||||
v-model="form.deploy_policy"
|
<SelectValue placeholder="Select deploy policy" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option v-for="policy in deployPolicies" :key="policy" :value="policy">
|
<SelectItem
|
||||||
{{ policy.replace("_", " ") }}
|
v-for="policy in deployPolicies"
|
||||||
</option>
|
:key="policy"
|
||||||
</select>
|
:value="policy"
|
||||||
|
>
|
||||||
|
{{ policy.replace("_", " ") }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.deploy_policy" />
|
<InputError :message="form.errors.deploy_policy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -182,7 +196,11 @@ const destroyService = (): void => {
|
|||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2 md:col-span-2">
|
<div class="grid gap-2 md:col-span-2">
|
||||||
<Label for="process_roles">Process roles</Label>
|
<Label for="process_roles">Process roles</Label>
|
||||||
<Input id="process_roles" v-model="form.process_roles" placeholder="web, scheduler" />
|
<Input
|
||||||
|
id="process_roles"
|
||||||
|
v-model="form.process_roles"
|
||||||
|
placeholder="web, scheduler"
|
||||||
|
/>
|
||||||
<InputError :message="form.errors.process_roles" />
|
<InputError :message="form.errors.process_roles" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ defineProps<{
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'Applications',
|
title: 'Applications',
|
||||||
href: route('applications.index', { organisation: $page.props.organisation.id }),
|
href: route('applications.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: application.name,
|
title: application.name,
|
||||||
@@ -45,7 +47,9 @@ defineProps<{
|
|||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
title: 'Servers',
|
title: 'Servers',
|
||||||
href: route('servers.index', { organisation: $page.props.organisation.id }),
|
href: route('servers.index', {
|
||||||
|
organisation: $page.props.organisation.id,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: server?.name ?? 'Server',
|
title: server?.name ?? 'Server',
|
||||||
@@ -197,7 +201,9 @@ defineProps<{
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Endpoints</CardTitle>
|
<CardTitle>Endpoints</CardTitle>
|
||||||
<CardDescription>Network endpoints registered for this service.</CardDescription>
|
<CardDescription
|
||||||
|
>Network endpoints registered for this service.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="grid gap-2">
|
<CardContent class="grid gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -225,20 +231,28 @@ defineProps<{
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Compose</CardTitle>
|
<CardTitle>Compose</CardTitle>
|
||||||
<CardDescription>Generated artifact location on the target server.</CardDescription>
|
<CardDescription
|
||||||
|
>Generated artifact location on the target server.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/services/{{ service.id }}/compose.yml</pre>
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||||
|
/home/keystone/services/{{ service.id }}/compose.yml</pre
|
||||||
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-if="service.type === 'caddy'">
|
<Card v-if="service.type === 'caddy'">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Caddyfile</CardTitle>
|
<CardTitle>Caddyfile</CardTitle>
|
||||||
<CardDescription>Gateway route configuration generated on the server.</CardDescription>
|
<CardDescription
|
||||||
|
>Gateway route configuration generated on the server.</CardDescription
|
||||||
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/gateway/Caddyfile</pre>
|
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||||
|
/home/keystone/gateway/Caddyfile</pre
|
||||||
|
>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ const resolveLatestDigest = (): void => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="confirmation">Type {{ service.name }} to confirm downtime</Label>
|
<Label for="confirmation"
|
||||||
|
>Type {{ service.name }} to confirm downtime</Label
|
||||||
|
>
|
||||||
<Input id="confirmation" v-model="form.confirmation" />
|
<Input id="confirmation" v-model="form.confirmation" />
|
||||||
<InputError :message="form.errors.confirmation" />
|
<InputError :message="form.errors.confirmation" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, useForm } from "@inertiajs/vue3";
|
import { Head, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -53,19 +60,20 @@ const form = useForm({
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="type">Type</Label>
|
<Label for="type">Type</Label>
|
||||||
<select
|
<Select v-model="form.type">
|
||||||
id="type"
|
<SelectTrigger id="type">
|
||||||
v-model="form.type"
|
<SelectValue placeholder="Select type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option
|
<SelectItem
|
||||||
v-for="sourceProviderType in sourceProviderTypes"
|
v-for="sourceProviderType in sourceProviderTypes"
|
||||||
:key="sourceProviderType"
|
:key="sourceProviderType"
|
||||||
:value="sourceProviderType"
|
:value="sourceProviderType"
|
||||||
>
|
>
|
||||||
{{ sourceProviderType.replace("_", " ") }}
|
{{ sourceProviderType.replace("_", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.type" />
|
<InputError :message="form.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import AppLayout from "@/layouts/AppLayout.vue";
|
import AppLayout from "@/layouts/AppLayout.vue";
|
||||||
import { Head, router, useForm } from "@inertiajs/vue3";
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
||||||
|
|
||||||
@@ -66,19 +73,20 @@ const destroySourceProvider = (): void => {
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="type">Type</Label>
|
<Label for="type">Type</Label>
|
||||||
<select
|
<Select v-model="form.type">
|
||||||
id="type"
|
<SelectTrigger id="type">
|
||||||
v-model="form.type"
|
<SelectValue placeholder="Select type" />
|
||||||
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option
|
<SelectItem
|
||||||
v-for="sourceProviderType in sourceProviderTypes"
|
v-for="sourceProviderType in sourceProviderTypes"
|
||||||
:key="sourceProviderType"
|
:key="sourceProviderType"
|
||||||
:value="sourceProviderType"
|
:value="sourceProviderType"
|
||||||
>
|
>
|
||||||
{{ sourceProviderType.replace("_", " ") }}
|
{{ sourceProviderType.replace("_", " ") }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<InputError :message="form.errors.type" />
|
<InputError :message="form.errors.type" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Http\Controllers\ServiceReplicaController;
|
|||||||
use App\Http\Controllers\ServiceSliceController;
|
use App\Http\Controllers\ServiceSliceController;
|
||||||
use App\Http\Controllers\ServiceUpdateController;
|
use App\Http\Controllers\ServiceUpdateController;
|
||||||
use App\Http\Controllers\SourceProviderController;
|
use App\Http\Controllers\SourceProviderController;
|
||||||
|
use App\Models\Application;
|
||||||
use App\Models\Operation;
|
use App\Models\Operation;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -41,13 +42,21 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
return inertia('Dashboard', [
|
return inertia('Dashboard', [
|
||||||
'organisations' => $organisations,
|
'organisations' => $organisations,
|
||||||
'recentOperations' => Operation::query()
|
'recentApplications' => Application::query()
|
||||||
->with('target')
|
->whereIn('organisation_id', $organisationIds)
|
||||||
|
->with(['sourceProvider', 'environments'])
|
||||||
|
->latest('updated_at')
|
||||||
|
->limit(6)
|
||||||
|
->get(),
|
||||||
|
'deployments' => Operation::query()
|
||||||
|
->with(['target' => fn ($morphTo) => $morphTo->morphWith([
|
||||||
|
Service::class => ['environment.application'],
|
||||||
|
])])
|
||||||
->whereHasMorph('target', [Service::class], fn ($query) => $query->whereIn('organisation_id', $organisationIds))
|
->whereHasMorph('target', [Service::class], fn ($query) => $query->whereIn('organisation_id', $organisationIds))
|
||||||
->latest()
|
->latest()
|
||||||
->limit(5)
|
->limit(15)
|
||||||
->get(),
|
->get(),
|
||||||
'unhealthyServices' => \App\Models\Service::query()
|
'unhealthyServices' => Service::query()
|
||||||
->whereIn('organisation_id', $organisationIds)
|
->whereIn('organisation_id', $organisationIds)
|
||||||
->whereNot('status', ServiceStatus::RUNNING)
|
->whereNot('status', ServiceStatus::RUNNING)
|
||||||
->latest()
|
->latest()
|
||||||
|
|||||||
100
tests/Feature/SimulatedEnvironmentSeederTest.php
Normal file
100
tests/Feature/SimulatedEnvironmentSeederTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
|
use App\Enums\EnvironmentVariableSource;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\ServiceReplica;
|
||||||
|
use Database\Seeders\DatabaseSeeder;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
$this->seed(DatabaseSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch any deployment jobs while seeding', function (): void {
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds an organisation with registries including a ready managed registry', function (): void {
|
||||||
|
$organisation = Organisation::where('name', 'Stratbucket')->firstOrFail();
|
||||||
|
|
||||||
|
expect($organisation->registries()->count())->toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
$managed = $organisation->registries()->where('type', RegistryType::MANAGED)->firstOrFail();
|
||||||
|
|
||||||
|
expect($managed->isReady())->toBeTrue()
|
||||||
|
->and($managed->health_status)->toBe('healthy')
|
||||||
|
->and($managed->control_server_id)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds a fleet of active servers with a control node', function (): void {
|
||||||
|
$organisation = Organisation::where('name', 'Stratbucket')->firstOrFail();
|
||||||
|
|
||||||
|
expect($organisation->servers()->where('is_control_node', true)->where('build_enabled', true)->count())->toBe(1)
|
||||||
|
->and($organisation->servers()->count())->toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds an application with production and staging environments', function (): void {
|
||||||
|
$application = Application::where('name', 'ClipBin')->firstOrFail();
|
||||||
|
|
||||||
|
expect($application->source_provider_id)->not->toBeNull()
|
||||||
|
->and($application->environments()->pluck('name')->sort()->values()->all())
|
||||||
|
->toBe(['production', 'staging']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires each environment with web, postgres, valkey and caddy services', function (): void {
|
||||||
|
$application = Application::where('name', 'ClipBin')->firstOrFail();
|
||||||
|
|
||||||
|
foreach ($application->environments as $environment) {
|
||||||
|
$types = $environment->services()->pluck('type')->map->value->sort()->values()->all();
|
||||||
|
|
||||||
|
expect($types)->toBe(['caddy', 'laravel', 'postgres', 'valkey'])
|
||||||
|
->and($environment->status)->toBe('active');
|
||||||
|
|
||||||
|
$postgres = $environment->services()->where('type', ServiceType::POSTGRES)->firstOrFail();
|
||||||
|
|
||||||
|
expect($postgres->status)->toBe(ServiceStatus::RUNNING)
|
||||||
|
->and($postgres->slices()->where('type', 'database_user')->where('status', 'active')->exists())->toBeTrue()
|
||||||
|
->and($postgres->endpoints()->exists())->toBeTrue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs managed database variables that are not overridable', function (): void {
|
||||||
|
$environment = Application::where('name', 'ClipBin')->firstOrFail()
|
||||||
|
->environments()->where('name', 'production')->firstOrFail();
|
||||||
|
|
||||||
|
$password = $environment->variables()->where('key', 'DB_PASSWORD')->firstOrFail();
|
||||||
|
|
||||||
|
expect($password->source)->toBe(EnvironmentVariableSource::MANAGED_ATTACHMENT)
|
||||||
|
->and($password->overridable)->toBeFalse()
|
||||||
|
->and($environment->variables()->where('key', 'APP_NAME')->where('source', EnvironmentVariableSource::USER)->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds build artifacts in available and building states', function (): void {
|
||||||
|
$statuses = Application::where('name', 'ClipBin')->firstOrFail()
|
||||||
|
->environments()
|
||||||
|
->with('buildArtifacts')
|
||||||
|
->get()
|
||||||
|
->flatMap->buildArtifacts
|
||||||
|
->pluck('status');
|
||||||
|
|
||||||
|
expect($statuses)->toContain(BuildArtifactStatus::AVAILABLE)
|
||||||
|
->and($statuses)->toContain(BuildArtifactStatus::BUILDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds an operations history with completed, in-progress and failed operations', function (): void {
|
||||||
|
expect(Operation::where('status', OperationStatus::COMPLETED)->exists())->toBeTrue()
|
||||||
|
->and(Operation::where('status', OperationStatus::IN_PROGRESS)->exists())->toBeTrue()
|
||||||
|
->and(Operation::where('status', OperationStatus::FAILED)->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds at least one unhealthy replica for state variety', function (): void {
|
||||||
|
expect(ServiceReplica::where('health_status', 'unhealthy')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user