Restructure UX and seed a fully simulated organisation
Some checks failed
CI / Tests (push) Failing after 56s
CI / Lint (push) Failing after 1m35s

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:
2026-06-08 22:09:57 +01:00
parent 3a851db08f
commit 85c44296ac
58 changed files with 2292 additions and 847 deletions

View File

@@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
- 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.
- 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.

View File

@@ -30,11 +30,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'organisation' => $request->route('organisation')
? Organisation::with('applications.environments')
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications'])
->findOrFail($this->routeKey($request->route('organisation')))
: null,
'organisation' => $this->resolveOrganisation($request),
'application' => $request->route('application')
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
: 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
{
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;

View File

@@ -4,11 +4,8 @@ namespace Database\Seeders;
use App\Enums\OrganisationRole;
use App\Enums\ProviderType;
use App\Enums\RepositoryType;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
@@ -36,36 +33,11 @@ class DatabaseSeeder extends Seeder
$provider = $organisation->providers()->create([
'name' => 'Hetzner',
'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'),
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]);
if (! app()->isProduction()) {
$network = $organisation->networks()->create([
'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',
]);
app(SimulatedEnvironmentSeeder::class)->seed($organisation, $provider);
}
}
}

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

View File

@@ -30,7 +30,7 @@ class TestEnvironmentSeeder extends Seeder
$organisation->providers()->create([
'name' => 'Hetzner',
'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'),
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]);
}
}

View File

@@ -7,6 +7,9 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -24,9 +27,9 @@ import type { BreadcrumbItem, NavItem } from "@/types";
import { Link, usePage } from "@inertiajs/vue3";
import {
AppWindowIcon,
BoltIcon,
BoxesIcon,
ClipboardListIcon,
Check,
ChevronsUpDown,
Menu,
Search,
ServerIcon,
@@ -45,7 +48,9 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage();
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(
() => (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) {
const organisationId = page.props?.organisation?.id;
mainNavItems.push({
title: page.props.organisation.name,
title: "Applications",
href: new URL(
route("organisations.show", {
route("applications.index", {
organisation: organisationId,
}),
).pathname,
icon: BoltIcon,
icon: AppWindowIcon,
});
mainNavItems.push({
title: "Environments",
@@ -83,15 +92,6 @@ if (page.props.organisation) {
).pathname,
icon: BoxesIcon,
});
mainNavItems.push({
title: "Applications",
href: new URL(
route("applications.index", {
organisation: organisationId,
}),
).pathname,
icon: AppWindowIcon,
});
mainNavItems.push({
title: "Servers",
href: new URL(
@@ -110,24 +110,6 @@ if (page.props.organisation) {
).pathname,
icon: WorkflowIcon,
});
if (
page.props.organisation.providers_count === 0 ||
page.props.organisation.source_providers_count === 0 ||
page.props.organisation.registries_count === 0 ||
page.props.organisation.servers_count === 0 ||
page.props.organisation.applications_count === 0
) {
mainNavItems.push({
title: "Onboarding",
href: new URL(
route("onboarding.show", {
organisation: organisationId,
}),
).pathname,
icon: ClipboardListIcon,
});
}
}
const rightNavItems: NavItem[] = [
@@ -206,6 +188,36 @@ const rightNavItems: NavItem[] = [
<AppLogo />
</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 -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">

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

View File

@@ -5,17 +5,20 @@ import ServiceStatus from "@/enums/ServiceStatus";
import ServiceType from "@/enums/ServiceType";
import { DoorOpenIcon } from "lucide-vue-next";
withDefaults(defineProps<{
withDefaults(
defineProps<{
icon?: object | Function;
serviceType?: string;
serviceCategory?: string;
status?: string;
}>(), {
}>(),
{
icon: () => DoorOpenIcon,
serviceType: ServiceType.GATEWAY,
serviceCategory: ServiceCategory.DATABASE,
status: ServiceStatus.UNKNOWN,
});
},
);
</script>
<template>
<Card

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

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

View File

@@ -13,7 +13,8 @@ defineProps<{
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 => {
if (!target) {
@@ -74,9 +75,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
<Badge
:variant="step.status === 'completed' ? 'success' : 'secondary'"
>
<Badge :variant="step.status === 'completed' ? 'success' : 'secondary'">
{{ label(step.status) }}
</Badge>
</div>
@@ -115,9 +114,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
>
{{ label(child.kind) }}
</Link>
<Badge
:variant="child.status === 'completed' ? 'success' : 'secondary'"
>
<Badge :variant="child.status === 'completed' ? 'success' : 'secondary'">
{{ label(child.status) }}
</Badge>
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
@@ -126,23 +123,35 @@ const targetLabel = (target?: Record<string, any> | null): string => {
</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.
</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">
<DialogHeader>
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
</DialogHeader>
<section v-if="selectedStep?.logs">
<h3 class="text-sm font-medium">Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.logs }}</pre>
<pre
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
>{{ selectedStep.logs }}</pre
>
</section>
<section v-if="selectedStep?.error_logs">
<h3 class="text-sm font-medium">Error Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.error_logs }}</pre>
<pre
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
>{{ selectedStep.error_logs }}</pre
>
</section>
</DialogContent>
</Dialog>

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,67 +1,249 @@
<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 AppLayout from "@/layouts/AppLayout.vue";
import { type BreadcrumbItem } from "@/types";
import { Head, Link } from "@inertiajs/vue3";
import { ChevronRightIcon } from "lucide-vue-next";
import { Head, Link, usePage } from "@inertiajs/vue3";
import { GitBranchIcon, PlusIcon } from "lucide-vue-next";
import { computed } from "vue";
defineProps<{
organisations: Record<string, any>[];
recentOperations: Record<string, any>[];
recentApplications: Record<string, any>[];
deployments: Record<string, any>[];
unhealthyServices: Record<string, any>[];
}>();
const page = usePage();
const organisation = computed(() => page.props.organisation);
const breadcrumbs: BreadcrumbItem[] = [
{
title: "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>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader class="border-b-muted-background border-b">
<CardTitle>Organisations</CardTitle>
<CardDescription>Select an organisation to view its environments.</CardDescription>
</CardHeader>
<CardContent class="divide-y-muted-foreground divide-y p-0">
<Link
v-for="organisation in organisations"
:key="organisation.id"
:href="route('organisations.show', { organisation: organisation.id })"
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
>
<div class="mx-auto w-full max-w-7xl space-y-8 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="font-medium">{{ organisation.name }}</div>
<div class="text-sm text-muted-foreground">
{{ organisation.applications_count }} applications ·
{{ organisation.servers_count }} servers ·
{{ organisation.services_count }} services
<h1 class="text-2xl font-bold tracking-tight">Overview</h1>
<p v-if="organisation" class="text-sm text-muted-foreground">
{{ organisation.name }}
</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
v-for="application in recentApplications"
:key="application.id"
:href="
route('applications.show', {
organisation: application.organisation_id,
application: application.id,
})
"
class="block"
>
<Card class="h-full transition-colors hover:border-foreground/20">
<CardContent class="flex flex-col gap-4 p-5">
<div class="flex items-start gap-3">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-sm font-semibold"
>
{{ initials(application.name) }}
</div>
<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>
<ChevronRightIcon class="size-4 text-muted-foreground" />
<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>
<Card
v-if="recentApplications.length === 0"
class="col-span-full border-dashed"
>
<CardContent class="flex flex-col items-center gap-3 p-8 text-center">
<p class="text-sm text-muted-foreground">No applications yet.</p>
<Button
v-if="organisation"
:as="Link"
size="sm"
:href="
route('applications.create', { organisation: organisation.id })
"
>
<PlusIcon class="size-4" />
Create your first application
</Button>
</CardContent>
</Card>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader>
<CardTitle>Latest deployments</CardTitle>
<CardDescription
>Recent operations across your organisations.</CardDescription
>
</CardHeader>
<CardContent class="p-0">
<ul class="divide-y">
<li
v-for="operation in deployments"
:key="operation.id"
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 across your organisations.</CardDescription>
<CardDescription>Services that need attention.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="service in unhealthyServices"
:key="service.id"
class="rounded-md border p-3 text-sm"
class="flex items-center justify-between rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ service.name }}</div>
<div class="text-muted-foreground">{{ service.status }}</div>
<span class="font-medium">{{ service.name }}</span>
<StatusIndicator :status="service.status" />
</div>
<div
v-if="unhealthyServices.length === 0"
@@ -71,32 +253,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
</CardContent>
</Card>
<Card class="lg:col-span-3">
<CardHeader>
<CardTitle>Recent operations</CardTitle>
<CardDescription>Latest service operations across your organisations.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="operation in recentOperations"
:key="operation.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div>
<div class="font-medium">{{ operation.kind.replace("_", " ") }}</div>
<div class="text-muted-foreground">{{ operation.hash }}</div>
</div>
<div class="text-muted-foreground">{{ operation.status.replace("-", " ") }}</div>
</div>
<div
v-if="recentOperations.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No operations recorded.
</div>
</CardContent>
</Card>
</section>
</div>
</AppLayout>
</template>

View File

@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, useForm } from "@inertiajs/vue3";
@@ -60,41 +67,44 @@ const form = useForm({
<CardContent class="grid gap-3 text-sm">
<div class="grid gap-2">
<Label for="repository_type">Repository type</Label>
<select
id="repository_type"
v-model="form.repository_type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option
<Select v-model="form.repository_type" required>
<SelectTrigger id="repository_type">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(type, key) in repositoryTypes"
:key="key"
:value="type"
>
{{ type }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.repository_type" />
</div>
<div v-if="sourceProviders.length" class="grid gap-2">
<Label for="source_provider_id">Source provider</Label>
<select
id="source_provider_id"
v-model="form.source_provider_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No provider</option>
<option
<Select v-model="form.source_provider_id">
<SelectTrigger id="source_provider_id">
<SelectValue placeholder="No provider" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="provider in sourceProviders"
:key="provider.id"
:value="provider.id"
:value="String(provider.id)"
>
{{ provider.name }} · {{ provider.type }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.source_provider_id" />
</div>
<div v-else class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3">
<div
v-else
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3"
>
<span class="text-muted-foreground">
No source provider is configured yet. SSH URLs still work, but adding a
provider documents which Git host this repository belongs to.
@@ -103,7 +113,11 @@ const form = useForm({
:as="Link"
size="sm"
variant="secondary"
:href="route('source-providers.create', { organisation: $page.props.organisation.id })"
:href="
route('source-providers.create', {
organisation: $page.props.organisation.id,
})
"
>
Add provider
</Button>

View File

@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -79,38 +86,38 @@ const destroyApplication = (): void => {
<CardContent class="grid gap-4">
<div class="grid gap-2">
<Label for="repository_type">Repository type</Label>
<select
id="repository_type"
v-model="form.repository_type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option
<Select v-model="form.repository_type" required>
<SelectTrigger id="repository_type">
<SelectValue placeholder="Select repository type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(type, key) in repositoryTypes"
:key="key"
:value="type"
>
{{ type }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.repository_type" />
</div>
<div class="grid gap-2">
<Label for="source_provider_id">Source provider</Label>
<select
id="source_provider_id"
v-model="form.source_provider_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No provider</option>
<option
<Select v-model="form.source_provider_id">
<SelectTrigger id="source_provider_id">
<SelectValue placeholder="No provider" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="provider in sourceProviders"
:key="provider.id"
:value="provider.id"
:value="String(provider.id)"
>
{{ provider.name }} · {{ provider.type }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.source_provider_id" />
</div>
<div class="grid gap-2">

View File

@@ -45,11 +45,7 @@ defineProps<{
</div>
</div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="application in applications"
:key="application.id"
class="relative w-full"
>
<Card v-for="application in applications" :key="application.id" class="relative w-full">
<CardHeader>
<CardTitle>{{ application.name }}</CardTitle>
<CardDescription

View File

@@ -79,7 +79,9 @@ defineProps<{
<CardTitle>Repository Deploy Key</CardTitle>
<Badge
: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>
<CardDescription>
This organisation has {{ deploymentRequirements.serverCount }}
servers and no registry. Multi-server deployments need a registry
so every server can pull the same build artifact.
servers and no registry. Multi-server deployments need a registry so
every server can pull the same build artifact.
</CardDescription>
</div>
<Button
@@ -310,7 +312,8 @@ defineProps<{
@click="
router.post(
route('environment-migrations.store', {
organisation: $page.props.organisation.id,
organisation:
$page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
@@ -335,7 +338,8 @@ defineProps<{
@click="
router.post(
route('environment-workers.store', {
organisation: $page.props.organisation.id,
organisation:
$page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
@@ -372,7 +376,9 @@ defineProps<{
>
<Badge variant="outline">{{ artifact.status }}</Badge>
<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>
</CardHeader>

View File

@@ -52,7 +52,9 @@ defineProps<{
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(artifact.metadata ?? {}, null, 2) }}</pre>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
JSON.stringify(artifact.metadata ?? {}, null, 2)
}}</pre>
</CardContent>
</Card>
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
@@ -16,7 +23,7 @@ const props = defineProps<{
}>();
const form = useForm({
service_id: props.services[0]?.id ?? null,
service_id: String(props.services[0]?.id ?? ""),
role: "database",
name: "",
env_prefix: "",
@@ -35,7 +42,7 @@ const compatibleServices = 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(() => {
if (selectedService.value?.type === "postgres") {
@@ -133,19 +140,20 @@ watch(
<div class="grid gap-2">
<Label for="service_id">Service</Label>
<select
id="service_id"
v-model="form.service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
<Select v-model="form.service_id">
<SelectTrigger id="service_id">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="service in compatibleServices"
:key="service.id"
:value="service.id"
:value="String(service.id)"
>
{{ service.name }} · {{ service.type }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.service_id" />
</div>
@@ -172,15 +180,16 @@ watch(
<div class="grid gap-2">
<Label for="role">Role</Label>
<select
id="role"
v-model="form.role"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="role in roles" :key="role" :value="role">
<Select v-model="form.role">
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.role" />
</div>
@@ -208,15 +217,28 @@ watch(
</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">
<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" />
</div>
<div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" type="text" placeholder="/" />
<Input
id="path_prefix"
v-model="form.path_prefix"
type="text"
placeholder="/"
/>
<InputError :message="form.errors.path_prefix" />
</div>
<label class="flex items-center gap-2 pt-7 text-sm">

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -78,15 +85,16 @@ const detach = (): void => {
<div class="grid gap-2">
<Label for="role">Role</Label>
<select
id="role"
v-model="form.role"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="role in roles" :key="role" :value="role">
<Select v-model="form.role">
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.role" />
</div>

View File

@@ -143,8 +143,10 @@ const destroyVariable = (variable: Record<string, any>): void => {
>
{{ variable.key }}
</Link>
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
{{ variable.source.replace('_', ' ') }}
<Badge
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
>
{{ variable.source.replace("_", " ") }}
</Badge>
<Badge v-if="!variable.overridable" variant="outline">locked</Badge>
<Badge variant="outline">secret</Badge>
@@ -159,7 +161,11 @@ const destroyVariable = (variable: Record<string, any>): void => {
<Button
size="iconxs"
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)"
>
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" />

View File

@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -123,33 +130,38 @@ const destroyEnvironment = (): void => {
</label>
<div class="grid gap-2">
<Label for="scheduler_target_service_id">Target service</Label>
<select
id="scheduler_target_service_id"
v-model="form.scheduler_target_service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No target</option>
<option
<Select v-model="form.scheduler_target_service_id">
<SelectTrigger id="scheduler_target_service_id">
<SelectValue placeholder="No target" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="service in environment.services"
:key="service.id"
:value="service.id"
:value="String(service.id)"
>
{{ service.name }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.scheduler_target_service_id" />
</div>
<div class="grid gap-2">
<Label for="scheduler_mode">Mode</Label>
<select
id="scheduler_mode"
v-model="form.scheduler_mode"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
<Select v-model="form.scheduler_mode">
<SelectTrigger id="scheduler_mode">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="mode in schedulerModes"
:key="mode"
:value="mode"
>
<option v-for="mode in schedulerModes" :key="mode" :value="mode">
{{ mode.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.scheduler_mode" />
</div>
</CardContent>
@@ -158,20 +170,27 @@ const destroyEnvironment = (): void => {
<Card>
<CardHeader>
<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>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="build_strategy">Build strategy</Label>
<select
id="build_strategy"
v-model="form.build_strategy"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
<Select v-model="form.build_strategy">
<SelectTrigger id="build_strategy">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="strategy in buildStrategies"
:key="strategy"
:value="strategy"
>
<option v-for="strategy in buildStrategies" :key="strategy" :value="strategy">
{{ strategy.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.build_strategy" />
</div>
<div class="grid gap-2">

View File

@@ -32,7 +32,9 @@ defineProps<{
</div>
<Button
:as="Link"
:href="route('applications.create', { organisation: $page.props.organisation.id })"
:href="
route('applications.create', { organisation: $page.props.organisation.id })
"
>
<PlusIcon class="size-4" />
Application
@@ -63,13 +65,17 @@ defineProps<{
<div class="flex items-center gap-2">
<BoxesIcon class="size-4" />
<span class="font-medium">{{ environment.name }}</span>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">
{{ environment.status.replace('-', ' ') }}
<Badge
:variant="
environment.status === 'active' ? 'success' : 'secondary'
"
>
{{ environment.status.replace("-", " ") }}
</Badge>
</div>
<p class="mt-2 text-sm text-muted-foreground">
{{ environment.branch }} · {{ environment.services_count }} services ·
{{ environment.build_artifacts_count }} builds
{{ environment.branch }} · {{ environment.services_count }} services
· {{ environment.build_artifacts_count }} builds
</p>
</Link>
</CardContent>

View File

@@ -1,22 +1,32 @@
<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 OperationTimeline from "@/components/operations/OperationTimeline.vue";
import StatusIndicator from "@/components/StatusIndicator.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import {
BoxIcon,
ChevronDownIcon,
DatabaseIcon,
GitBranchIcon,
GlobeIcon,
LayersIcon,
ListChecksIcon,
PencilIcon,
PlusIcon,
RocketIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
ZapIcon,
} from "lucide-vue-next";
import { computed } from "vue";
@@ -38,13 +48,24 @@ const gatewayAttachments = computed(() =>
props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
);
const gatewayCutovers = computed(() =>
props.environment.operations.filter((operation) => operation.kind === "gateway_cutover"),
const resourceAttachments = computed(() =>
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 =>
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)?.caddyfile ??
"# No route preview available";
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)
?.caddyfile ?? "# No route preview available";
const deployForm = useForm({
target_commit: "",
@@ -83,37 +104,40 @@ const deployEnvironment = (): void => {
{ title: environment.name },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="mx-auto w-full max-w-7xl space-y-6 p-4">
<!-- Header -->
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ environment.name }}</h2>
<Badge
:variant="environment.status === 'active' ? 'success' : 'secondary'"
>{{ environment.status.replace("-", " ") }}</Badge
>
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold tracking-tight">{{ environment.name }}</h2>
<StatusIndicator :status="environment.status" size="md" />
</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">
<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,
service.id ===
environment.scheduler_target_service_id,
)?.name ?? "selected service"
}`
: "disabled"
}}
</p>
</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
:as="Link"
variant="secondary"
:as="Link"
:href="
route('environments.edit', {
organisation: $page.props.organisation.id,
@@ -141,8 +165,8 @@ const deployEnvironment = (): void => {
Migrate
</Button>
<Button
:as="Link"
variant="secondary"
:as="Link"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id,
@@ -154,48 +178,27 @@ const deployEnvironment = (): void => {
<PlusIcon class="size-4" />
Attach
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Deploy Target</CardTitle>
<CardDescription>
Deploy the current {{ environment.branch }} branch head, or pin this
deployment to a specific commit SHA.
</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-3 md:flex-row md:items-end" @submit.prevent="deployEnvironment">
<div class="grid flex-1 gap-2">
<Label for="target_commit">Commit SHA</Label>
<Input
id="target_commit"
v-model="deployForm.target_commit"
placeholder="Leave blank to resolve the branch head"
maxlength="40"
/>
<InputError :message="deployForm.errors.target_commit" />
</div>
<Button
type="submit"
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
@click="deployEnvironment"
>
<RocketIcon class="size-4" />
Deploy
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
<Card v-if="deploymentRequirements.registryRequired" class="border-amber-200 bg-amber-50">
<Card
v-if="deploymentRequirements.registryRequired"
class="border-amber-300 bg-amber-50 dark:bg-amber-950/30"
>
<CardHeader>
<CardTitle>Registry Required</CardTitle>
<CardTitle>Registry required</CardTitle>
<CardDescription>
This environment spans {{ deploymentRequirements.serverCount }} servers.
Configure a registry before deploying so every server can pull the same
@@ -217,39 +220,109 @@ const deployEnvironment = (): void => {
</CardContent>
</Card>
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Services</CardTitle>
<CardDescription
>{{ environment.services?.length ?? 0 }} runtime and managed
services</CardDescription
<!-- Topology -->
<div class="grid gap-4 lg:grid-cols-3">
<!-- Network -->
<div class="space-y-3">
<h3 class="px-1 text-sm font-medium text-muted-foreground">Network</h3>
<TopologyCard
v-for="attachment in gatewayAttachments"
:key="attachment.id"
:icon="GlobeIcon"
title="Gateway"
:subtitle="attachment.service_slice?.config?.domain ?? 'Unassigned domain'"
:status="attachment.service_slice?.config?.certificate_status ?? 'pending'"
>
</CardHeader>
<CardContent class="grid gap-3">
<div
v-for="service in environment.services"
:key="service.id"
class="rounded-md border p-3"
<SpecRow
:icon="GlobeIcon"
label="Domain"
:value="attachment.service_slice?.config?.domain ?? 'not set'"
/>
<SpecRow
:icon="ServerIcon"
label="Path"
:value="attachment.service_slice?.config?.path_prefix ?? '/'"
/>
<SpecRow :icon="ShieldCheckIcon" label="TLS">
<StatusIndicator
:status="
attachment.service_slice?.config?.tls_enabled === false
? 'disabled'
: 'enabled'
"
/>
</SpecRow>
<Collapsible>
<CollapsibleTrigger
class="flex w-full items-center justify-between pt-1 text-xs text-muted-foreground hover:text-foreground"
>
<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("-", " ") }}
View Caddyfile
<ChevronDownIcon class="size-3.5" />
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
caddyfilePreviewFor(attachment.id)
}}</pre>
</CollapsibleContent>
</Collapsible>
</TopologyCard>
<Card v-if="gatewayAttachments.length === 0" class="border-dashed">
<CardContent class="space-y-3 p-5 text-center">
<p class="text-sm text-muted-foreground">
No gateway routes configured.
</p>
</div>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
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,
@@ -260,16 +333,170 @@ const deployEnvironment = (): void => {
"
>
<SettingsIcon class="size-4" />
Open
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>
</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,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Attach resource
</Button>
</CardContent>
</Card>
<!-- Variables -->
<Card>
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-base">Variables</CardTitle>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('environment-variables.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage
</Button>
</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>
</Card>
</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>Operations</CardTitle>
<CardTitle class="text-base">Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="environment.operations" />
@@ -278,16 +505,11 @@ const deployEnvironment = (): void => {
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-3">
<div>
<CardTitle>Builds</CardTitle>
<CardDescription>
Recent artifacts planned or built for this environment.
</CardDescription>
</div>
<div class="flex items-center justify-between">
<CardTitle class="text-base">Builds</CardTitle>
<Button
:as="Link"
size="sm"
size="xs"
variant="secondary"
:href="
route('build-artifacts.index', {
@@ -308,228 +530,22 @@ const deployEnvironment = (): void => {
class="rounded-md border p-3 text-sm"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{{ artifact.status }}</Badge>
<span class="font-medium">{{ artifact.commit_sha }}</span>
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
<StatusIndicator :status="artifact.status" />
<span class="font-mono text-xs">{{ artifact.commit_sha }}</span>
</div>
<p class="mt-1 text-muted-foreground">
<p class="mt-1 truncate text-xs 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"
v-if="(environment.build_artifacts?.length ?? 0) === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No builds recorded for this environment.
No builds recorded.
</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">{{
caddyfilePreviewFor(attachment.id)
}}</pre>
<div class="mt-2 flex flex-wrap gap-2">
<Badge variant="outline">Render route</Badge>
<Badge variant="outline">Health check</Badge>
<Badge variant="outline">Reload gateway</Badge>
<Badge variant="outline">Drain old upstream</Badge>
</div>
</div>
<OperationTimeline :operations="gatewayCutovers" />
</CardContent>
</Card>
<Card v-else>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Gateway Routes</CardTitle>
<CardDescription>
No gateway routes are configured for this environment.
</CardDescription>
</div>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>Variables</CardTitle>
</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 }} · {{ variable.source.replace("_", " ") }}
<span v-if="!variable.overridable"> · locked</span>
</Badge>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('environment-variables.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Manage
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Service policy</CardTitle>
<CardDescription>
Migration and scheduler-related defaults exposed by current
services.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<div
v-for="service in environment.services"
:key="service.id"
class="rounded-md border p-3"
>
<div class="font-medium">{{ service.name }}</div>
<div class="text-muted-foreground">
Deploy policy: {{ service.deploy_policy ?? "default" }} ·
Roles: {{ service.process_roles?.join(", ") || "none" }}
</div>
<div class="text-muted-foreground">
Migration:
{{
service.config?.migration_mode ??
service.config?.migration_timing ??
"not configured"
}}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
@@ -13,7 +20,7 @@ const props = defineProps<{
}>();
const form = useForm({
service_id: props.services[0]?.id ?? null,
service_id: String(props.services[0]?.id ?? ""),
name: "",
domain: "",
path_prefix: "/",
@@ -77,16 +84,20 @@ const form = useForm({
<div class="grid gap-2">
<Label for="service_id">Gateway service</Label>
<select
id="service_id"
v-model="form.service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
<Select v-model="form.service_id" required>
<SelectTrigger id="service_id">
<SelectValue placeholder="Select gateway service" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="service in services"
:key="service.id"
:value="String(service.id)"
>
<option v-for="service in services" :key="service.id" :value="service.id">
{{ service.name }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.service_id" />
</div>
@@ -99,7 +110,12 @@ const form = useForm({
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
<Input
id="domain"
v-model="form.domain"
placeholder="app.example.com"
required
/>
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">

View File

@@ -94,7 +94,12 @@ const destroyRoute = (): void => {
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
<Input
id="domain"
v-model="form.domain"
placeholder="app.example.com"
required
/>
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">
@@ -111,7 +116,11 @@ const destroyRoute = (): void => {
<div class="grid gap-2">
<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" />
</div>

View File

@@ -13,7 +13,8 @@ const props = defineProps<{
}>();
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}?`)) {
return;
@@ -87,7 +88,10 @@ const destroyRoute = (routeAttachment: Record<string, any>): void => {
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>
{{ routeAttachment.service_slice?.config?.domain ?? "Unassigned domain" }}
{{
routeAttachment.service_slice?.config?.domain ??
"Unassigned domain"
}}
</CardTitle>
<CardDescription>
{{ routeAttachment.service?.name }} ·

View File

@@ -65,7 +65,7 @@ const setFilter = (key: string, value: string | null): void => {
:variant="filters.kind === kind ? 'default' : 'secondary'"
@click="setFilter('kind', kind)"
>
{{ kind.replace('_', ' ') }}
{{ kind.replace("_", " ") }}
</Button>
<Button
v-for="status in operationStatuses"
@@ -74,7 +74,7 @@ const setFilter = (key: string, value: string | null): void => {
:variant="filters.status === status ? 'default' : 'outline'"
@click="setFilter('status', filters.status === status ? null : status)"
>
{{ status.replace('-', ' ') }}
{{ status.replace("-", " ") }}
</Button>
</CardContent>
</Card>

View File

@@ -10,7 +10,8 @@ defineProps<{
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 });
@@ -49,9 +50,13 @@ const cancelOperation = (operation: Record<string, any>): void => {
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ label(operation.kind) }}</h2>
<h2 class="text-3xl font-bold tracking-tight">
{{ label(operation.kind) }}
</h2>
<Badge variant="outline">{{ operation.hash }}</Badge>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">
<Badge
:variant="operation.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(operation.status) }}
</Badge>
</div>

View File

@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
import { Trash2Icon } from "lucide-vue-next";
@@ -93,7 +100,8 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<CardHeader>
<CardTitle>Invite Member</CardTitle>
<CardDescription>
Existing users are added immediately. New emails remain pending until accepted.
Existing users are added immediately. New emails remain pending until
accepted.
</CardDescription>
</CardHeader>
<CardContent>
@@ -104,7 +112,10 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
route('organisation-members.store', {
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 class="grid gap-2">
<Label for="role">Role</Label>
<select
id="role"
v-model="inviteForm.role"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="role in roles" :key="role" :value="role">
<Select v-model="inviteForm.role">
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="inviteForm.errors.role" />
</div>
<div class="flex items-end">
@@ -151,23 +163,24 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<div class="text-muted-foreground">
Invited by
{{ invitation.invited_by?.name ?? "Keystone" }}
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span>
</div>
</div>
<select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
:value="invitation.role ?? 'member'"
@change="
updateInvitationRole(
invitation,
($event.target as HTMLSelectElement).value,
)
"
<span v-if="invitation.expires_at">
· expires {{ invitation.expires_at }}</span
>
<option v-for="role in roles" :key="role" :value="role">
</div>
</div>
<Select
:model-value="invitation.role ?? 'member'"
@update:model-value="updateInvitationRole(invitation, $event as string)"
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<Button
size="iconxs"
variant="ghost"
@@ -201,15 +214,19 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<div class="font-medium">{{ member.name }}</div>
<div class="text-muted-foreground">{{ member.email }}</div>
</div>
<select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
:value="member.membership?.role ?? 'member'"
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
<Select
:model-value="member.membership?.role ?? 'member'"
@update:model-value="updateRole(member, $event as string)"
>
<option v-for="role in roles" :key="role" :value="role">
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<Button
size="iconxs"
variant="ghost"

View File

@@ -153,20 +153,30 @@ const destroyResource = (url: string, label: string): void => {
<Card class="mt-4">
<CardHeader>
<CardTitle>Health</CardTitle>
<CardDescription>Aggregate signals across this organisation.</CardDescription>
<CardDescription
>Aggregate signals across this organisation.</CardDescription
>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-3">
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.unhealthy_services }}</div>
<div class="text-2xl font-semibold">
{{ health.unhealthy_services }}
</div>
<div class="text-sm text-muted-foreground">Unhealthy services</div>
</div>
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.failed_operations }}</div>
<div class="text-2xl font-semibold">
{{ health.failed_operations }}
</div>
<div class="text-sm text-muted-foreground">Failed operations</div>
</div>
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.locked_variables }}</div>
<div class="text-sm text-muted-foreground">Environments with locked variables</div>
<div class="text-2xl font-semibold">
{{ health.locked_variables }}
</div>
<div class="text-sm text-muted-foreground">
Environments with locked variables
</div>
</div>
</CardContent>
</Card>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
@@ -51,15 +58,16 @@ const form = useForm({
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="type in providerTypes" :key="type" :value="type">
<Select v-model="form.type">
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="type in providerTypes" :key="type" :value="type">
{{ type.replace("-", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" />
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
@@ -53,19 +60,20 @@ const form = useForm({
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
<Select v-model="form.type">
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="registryType in registryTypes"
:key="registryType"
:value="registryType"
>
{{ registryType.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" />
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -71,15 +78,20 @@ const destroyRegistry = (): void => {
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
<Select v-model="form.type">
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="registryType in registryTypes"
:key="registryType"
:value="registryType"
>
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType">
{{ registryType.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" />
</div>

View File

@@ -46,7 +46,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
</div>
<Button
:as="Link"
:href="route('registries.create', { organisation: $page.props.organisation.id })"
:href="
route('registries.create', { organisation: $page.props.organisation.id })
"
>
<PlusIcon class="size-4" />
Add registry
@@ -67,7 +69,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ registry.name }}</span>
<Badge variant="outline">{{ registry.type?.replace("_", " ") }}</Badge>
<Badge variant="outline">{{
registry.type?.replace("_", " ")
}}</Badge>
</div>
<div class="mt-1 text-muted-foreground">
{{ registry.url ?? "No registry URL configured" }}
@@ -100,7 +104,11 @@ const destroyRegistry = (registry: Record<string, any>): void => {
>
<PencilIcon class="size-3" />
</Button>
<Button size="iconxs" variant="ghost" @click="destroyRegistry(registry)">
<Button
size="iconxs"
variant="ghost"
@click="destroyRegistry(registry)"
>
<Trash2Icon class="size-3" />
</Button>
</div>

View File

@@ -47,11 +47,7 @@ defineProps<{
</div>
</div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="server in servers.data"
:key="server.id"
class="relative w-full"
>
<Card v-for="server in servers.data" :key="server.id" class="relative w-full">
<CardHeader>
<CardTitle>{{ server.name }}</CardTitle>
<CardDescription>
@@ -151,7 +147,8 @@ defineProps<{
<CardHeader>
<CardTitle>No private networks</CardTitle>
<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>
</CardHeader>
</Card>

View File

@@ -6,6 +6,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
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>
<Card>
<CardContent class="py-4">
<OperationTimeline :operations="server.service_operations" show-target />
<OperationTimeline
:operations="server.service_operations"
show-target
/>
</CardContent>
</Card>
</div>
@@ -212,22 +222,29 @@ const healServer = (): void => {
<Card>
<CardHeader>
<CardTitle>Firewall</CardTitle>
<CardDescription>Rules Keystone knows about for this server.</CardDescription>
<CardDescription
>Rules Keystone knows about for this server.</CardDescription
>
</CardHeader>
<CardContent class="grid gap-4">
<form class="grid gap-3 rounded-md border p-3" @submit.prevent="addFirewallRule">
<div class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end">
<form
class="grid gap-3 rounded-md border p-3"
@submit.prevent="addFirewallRule"
>
<div
class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end"
>
<div class="grid gap-2">
<Label for="firewall_type">Action</Label>
<select
id="firewall_type"
v-model="firewallForm.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
<Select v-model="firewallForm.type" required>
<SelectTrigger id="firewall_type">
<SelectValue placeholder="Select an action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">allow</SelectItem>
<SelectItem value="deny">deny</SelectItem>
</SelectContent>
</Select>
<InputError :message="firewallForm.errors.type" />
</div>
<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"
>
<div>
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div>
<div class="font-medium">
{{ rule.type }} · {{ rule.ports }}
</div>
<div class="text-muted-foreground">
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
{{ rule.status }}
@@ -293,10 +312,13 @@ const healServer = (): void => {
<div v-if="server.network">
<div class="font-medium">{{ server.network.name }}</div>
<div class="text-muted-foreground">
{{ server.network.ip_range }} · {{ server.network.network_zone }}
{{ server.network.ip_range }} ·
{{ server.network.network_zone }}
</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>
</Card>
</div>
@@ -325,7 +347,11 @@ const healServer = (): void => {
</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" />
Add service
</Button>

View File

@@ -82,7 +82,9 @@ const stopReplica = (): void => {
{{ replica.container_name }}
</h2>
<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 }}
</Badge>
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
@@ -77,16 +84,20 @@ const form = useForm({
<div class="grid gap-2">
<Label for="environment_id">Environment</Label>
<select
id="environment_id"
v-model="form.environment_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
<Select v-model="form.environment_id">
<SelectTrigger id="environment_id">
<SelectValue placeholder="Service-level" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="environment in environments"
:key="environment.id"
:value="String(environment.id)"
>
<option value="">Service-level</option>
<option v-for="environment in environments" :key="environment.id" :value="environment.id">
{{ environment.name }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.environment_id" />
</div>

View File

@@ -86,20 +86,30 @@ defineProps<{
</CardTitle>
<CardDescription>
{{ slice.environment?.application?.name ?? "Service" }}
<span v-if="slice.environment">/ {{ slice.environment.name }}</span>
<span v-if="slice.environment"
>/ {{ slice.environment.name }}</span
>
</CardDescription>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">{{ slice.type }}</Badge>
<Badge :variant="slice.status === 'active' ? 'success' : 'secondary'">
<Badge
:variant="slice.status === 'active' ? 'success' : 'secondary'"
>
{{ slice.status }}
</Badge>
<Badge variant="outline">{{ slice.attachments.length }} attachments</Badge>
<Badge variant="outline"
>{{ slice.attachments.length }} attachments</Badge
>
</div>
</div>
</CardHeader>
<CardContent 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>
<CardContent
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 class="mb-2 text-sm font-medium">Recent operations</div>
<OperationTimeline :operations="slice.operations" />
@@ -110,7 +120,10 @@ defineProps<{
<Card v-if="slices.length === 0" class="border-dashed">
<CardHeader>
<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>
<CardContent>
<Button

View File

@@ -46,10 +46,15 @@ defineProps<{
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>Credentials are stored encrypted and not revealed here.</CardDescription>
<CardDescription
>Credentials are stored encrypted and not revealed
here.</CardDescription
>
</CardHeader>
<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>
</Card>
@@ -71,7 +76,7 @@ defineProps<{
"
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">
{{ attachment.environment.name }} ·
{{ attachment.env_prefix ?? "default prefix" }}

View File

@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -146,20 +153,27 @@ const destroyService = (): void => {
<Card>
<CardHeader>
<CardTitle>Deployment</CardTitle>
<CardDescription>Stateful services can track available image updates.</CardDescription>
<CardDescription
>Stateful services can track available image updates.</CardDescription
>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="deploy_policy">Deploy policy</Label>
<select
id="deploy_policy"
v-model="form.deploy_policy"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
<Select v-model="form.deploy_policy">
<SelectTrigger id="deploy_policy">
<SelectValue placeholder="Select deploy policy" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="policy in deployPolicies"
:key="policy"
:value="policy"
>
<option v-for="policy in deployPolicies" :key="policy" :value="policy">
{{ policy.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.deploy_policy" />
</div>
<div class="grid gap-2">
@@ -182,7 +196,11 @@ const destroyService = (): void => {
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<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" />
</div>
<div class="grid gap-2">

View File

@@ -24,7 +24,9 @@ defineProps<{
? [
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
href: route('applications.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: application.name,
@@ -45,7 +47,9 @@ defineProps<{
: [
{
title: 'Servers',
href: route('servers.index', { organisation: $page.props.organisation.id }),
href: route('servers.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: server?.name ?? 'Server',
@@ -197,7 +201,9 @@ defineProps<{
<Card>
<CardHeader>
<CardTitle>Endpoints</CardTitle>
<CardDescription>Network endpoints registered for this service.</CardDescription>
<CardDescription
>Network endpoints registered for this service.</CardDescription
>
</CardHeader>
<CardContent class="grid gap-2">
<div
@@ -225,20 +231,28 @@ defineProps<{
<Card>
<CardHeader>
<CardTitle>Compose</CardTitle>
<CardDescription>Generated artifact location on the target server.</CardDescription>
<CardDescription
>Generated artifact location on the target server.</CardDescription
>
</CardHeader>
<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>
</Card>
<Card v-if="service.type === 'caddy'">
<CardHeader>
<CardTitle>Caddyfile</CardTitle>
<CardDescription>Gateway route configuration generated on the server.</CardDescription>
<CardDescription
>Gateway route configuration generated on the server.</CardDescription
>
</CardHeader>
<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>
</Card>
</div>

View File

@@ -112,7 +112,9 @@ const resolveLatestDigest = (): void => {
</div>
<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" />
<InputError :message="form.errors.confirmation" />
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
@@ -53,19 +60,20 @@ const form = useForm({
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
<Select v-model="form.type">
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="sourceProviderType in sourceProviderTypes"
:key="sourceProviderType"
:value="sourceProviderType"
>
{{ sourceProviderType.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" />
</div>

View File

@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
@@ -66,19 +73,20 @@ const destroySourceProvider = (): void => {
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
<Select v-model="form.type">
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="sourceProviderType in sourceProviderTypes"
:key="sourceProviderType"
:value="sourceProviderType"
>
{{ sourceProviderType.replace("_", " ") }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" />
</div>

View File

@@ -26,6 +26,7 @@ use App\Http\Controllers\ServiceReplicaController;
use App\Http\Controllers\ServiceSliceController;
use App\Http\Controllers\ServiceUpdateController;
use App\Http\Controllers\SourceProviderController;
use App\Models\Application;
use App\Models\Operation;
use App\Models\Service;
use Illuminate\Support\Facades\Route;
@@ -41,13 +42,21 @@ Route::middleware(['auth', 'verified'])->group(function () {
return inertia('Dashboard', [
'organisations' => $organisations,
'recentOperations' => Operation::query()
->with('target')
'recentApplications' => Application::query()
->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))
->latest()
->limit(5)
->limit(15)
->get(),
'unhealthyServices' => \App\Models\Service::query()
'unhealthyServices' => Service::query()
->whereIn('organisation_id', $organisationIds)
->whereNot('status', ServiceStatus::RUNNING)
->latest()

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