Implement Keystone environment deployments

This commit is contained in:
2026-05-13 16:11:23 +01:00
parent 65d3142d03
commit aa680b25fd
175 changed files with 10258 additions and 740 deletions

View File

@@ -0,0 +1,9 @@
// This is a generated file.
export default {
"PENDING": "pending",
"BUILDING": "building",
"AVAILABLE": "available",
"FAILED": "failed"
}

View File

@@ -0,0 +1,8 @@
// This is a generated file.
export default {
"TARGET_SERVER": "target_server",
"DEDICATED_BUILDER": "dedicated_builder",
"EXTERNAL_REGISTRY": "external_registry"
}

View File

@@ -0,0 +1,9 @@
// This is a generated file.
export default {
"WITH_ENVIRONMENT": "with_environment",
"DEPENDENCY_ONLY": "dependency_only",
"MANUAL_OR_ON_ROUTE_CHANGE": "manual_or_on_route_change",
"MANUAL": "manual"
}

View File

@@ -0,0 +1,11 @@
// This is a generated file.
export default {
"DATABASE": "database",
"CACHE": "cache",
"QUEUE": "queue",
"STORAGE": "storage",
"GATEWAY": "gateway",
"CUSTOM": "custom"
}

View File

@@ -0,0 +1,8 @@
// This is a generated file.
export default {
"USER": "user",
"MANAGED_ATTACHMENT": "managed_attachment",
"SYSTEM": "system"
}

View File

@@ -0,0 +1,14 @@
// This is a generated file.
export default {
"SERVER_PROVISION": "server_provision",
"SERVICE_DEPLOY": "service_deploy",
"REPLICA_DEPLOY": "replica_deploy",
"SLICE_PROVISION": "slice_provision",
"SLICE_CONFIGURE": "slice_configure",
"ENVIRONMENT_DEPLOY": "environment_deploy",
"GATEWAY_CUTOVER": "gateway_cutover",
"CONFIG_CHANGE": "config_change",
"CREDENTIAL_ROTATION": "credential_rotation"
}

View File

@@ -0,0 +1,9 @@
// This is a generated file.
export default {
"GENERIC": "generic",
"GITEA": "gitea",
"GHCR": "ghcr",
"DOCKER_HUB": "docker_hub"
}

View File

@@ -0,0 +1,7 @@
// This is a generated file.
export default {
"SINGLE": "single",
"EVERY_REPLICA": "every_replica"
}

View File

@@ -5,14 +5,16 @@ export default {
"APPLICATION": "application",
"GATEWAY": "gateway",
"STORAGE": "storage",
"CACHE": "cache"
"CACHE": "cache",
"BUILDER": "builder"
}
export const DescriptionMap = {
"DATABASE": "Postgres or MySQL",
"DATABASE": "Postgres",
"APPLICATION": "The base container image for your application",
"GATEWAY": "The first point of contact for your application",
"STORAGE": "S3 or S3-compatible service",
"CACHE": "Redis, Memcached or similar"
"CACHE": "Valkey",
"BUILDER": "Build service for application artifacts"
}

View File

@@ -0,0 +1,8 @@
// This is a generated file.
export default {
"DOCKER_NETWORK": "docker_network",
"PRIVATE_NETWORK": "private_network",
"PUBLIC": "public"
}

View File

@@ -1,13 +1,9 @@
// This is a generated file.
export default {
"FRANKENPHP": "frankenphp",
"PHP_FPM": "php-fpm",
"POSTGRES": "postgres",
"CADDY": "caddy",
"VALKEY": "valkey",
"MYSQL": "mysql",
"NGINX": "nginx",
"REDIS": "redis"
"LARAVEL": "laravel"
}

View File

@@ -0,0 +1,8 @@
// This is a generated file.
export default {
"GITEA": "gitea",
"GITHUB": "github",
"GENERIC_GIT": "generic_git"
}

View File

@@ -0,0 +1,72 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
const form = useForm({
name: '',
repository_url: '',
default_branch: 'main',
environment_name: 'production',
});
</script>
<template>
<Head title="Create Application" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: 'Create',
},
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('applications.store', { organisation: $page.props.organisation.id }))"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Application</h2>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" type="text" required autofocus placeholder="Billing API" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="repository_url">Repository SSH URL</Label>
<Input id="repository_url" v-model="form.repository_url" type="text" required placeholder="git@example.com:org/repo.git" />
<InputError :message="form.errors.repository_url" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="default_branch">Default branch</Label>
<Input id="default_branch" v-model="form.default_branch" type="text" required />
<InputError :message="form.errors.default_branch" />
</div>
<div class="grid gap-2">
<Label for="environment_name">Environment</Label>
<Input id="environment_name" v-model="form.environment_name" type="text" required />
<InputError :message="form.errors.environment_name" />
</div>
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">Create</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -1,7 +1,6 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
@@ -26,18 +25,26 @@ const props = defineProps({
},
]"
>
<div class="flex justify-between items-center gap-3 p-4">
<div class="flex items-center justify-between gap-3 p-4">
<h2 class="text-3xl font-bold tracking-tight">Applications</h2>
<div>
<!-- <Button :as="Link" :href="route('applications.create', {
organisation: $page.props.organisation.id,
})">Create</Button> -->
<Button
:as="Link"
:href="
route('applications.create', {
organisation: $page.props.organisation.id,
})
"
>
Create
</Button>
</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{$applications.id}`" class="relative w-full">
<CardHeader>
<CardTitle>{{ application.name }}</CardTitle>
<CardDescription>{{ application.environments?.length ?? 0 }} environments</CardDescription>
</CardHeader>
<Link
:href="

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { ServerIcon } from 'lucide-vue-next';
import { Head, Link, router } from '@inertiajs/vue3';
import { BoxesIcon, ExternalLinkIcon, GitBranchIcon, KeyRoundIcon, RocketIcon } from 'lucide-vue-next';
const props = defineProps({
application: {
@@ -36,33 +37,154 @@ const props = defineProps({
<h2 class="text-3xl font-bold tracking-tight">{{ application.name }}</h2>
</div>
<Card v-if="application.deploy_key_public && !application.deploy_key_installed_at">
<CardHeader>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-3">
<div class="flex items-center gap-2">
<KeyRoundIcon class="size-4" />
<CardTitle>Repository Deploy Key</CardTitle>
</div>
<pre class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs">{{ application.deploy_key_public }}</pre>
</div>
<Button
class="shrink-0"
@click="
router.post(
route('applications.verify-repository', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
"
>
<GitBranchIcon class="size-4" />
Verify
</Button>
</div>
</CardHeader>
</Card>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-2xl font-semibold tracking-tight">Server Instances</h3>
<div>
<!-- Add instance button would go here -->
</div>
<h3 class="text-2xl font-semibold tracking-tight">Environments</h3>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="instance in application.instances" :key="instance.id" class="relative">
<Link
class="absolute inset-0"
:href="
route('servers.show', {
organisation: $page.props.organisation.id,
server: instance.server.id,
})
"
></Link>
<Card v-for="environment in application.environments" :key="environment.id" class="relative">
<CardHeader>
<div class="flex items-center gap-2">
<ServerIcon class="size-4" />
<CardTitle>{{ instance.server.name }}</CardTitle>
<Badge :variant="instance.status === 'active' ? 'success' : 'secondary'">{{
instance.status.replace('-', ' ')
}}</Badge>
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<BoxesIcon class="size-4" />
<CardTitle>{{ environment.name }}</CardTitle>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">{{
environment.status.replace('-', ' ')
}}</Badge>
</div>
<CardDescription>
Branch: {{ environment.branch }} &bull; {{ environment.services?.length ?? 0 }} services
</CardDescription>
<div v-if="environment.variables?.length" class="mt-3 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>
</div>
</div>
<div class="flex shrink-0 gap-2">
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<ExternalLinkIcon class="size-4" />
Open
</Button>
<Button
size="xs"
@click="
router.post(
route('environment-deployments.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<RocketIcon class="size-4" />
Deploy
</Button>
<Button
size="xs"
variant="secondary"
@click="
router.post(
route('environment-migrations.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
Migrate
</Button>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('environment-variables.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Env
</Button>
<Button
size="xs"
variant="secondary"
@click="
router.post(
route('environment-workers.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
Worker
</Button>
<Button
:as="Link"
size="xs"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Attach
</Button>
</div>
</div>
<CardDescription> Branch: {{ instance.branch }} </CardDescription>
</CardHeader>
</Card>
</div>

View File

@@ -0,0 +1,164 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
services: {
type: Array,
required: true,
},
roles: {
type: Array,
required: true,
},
});
const form = useForm({
service_id: props.services[0]?.id ?? null,
role: 'database',
name: '',
env_prefix: '',
is_primary: true,
});
const compatibleServices = computed(() => {
const roleTypes = {
database: ['postgres'],
cache: ['valkey'],
queue: ['valkey'],
gateway: ['caddy'],
};
return props.services.filter((service) => (roleTypes[form.role] ?? props.services.map((item) => item.type)).includes(service.type));
});
const selectedService = computed(() => props.services.find((service) => service.id === form.service_id));
const generatedSliceType = computed(() => {
if (selectedService.value?.type === 'postgres') {
return 'database user';
}
if (selectedService.value?.type === 'valkey') {
return 'logical database';
}
if (selectedService.value?.type === 'caddy') {
return 'route';
}
return 'service link';
});
watch(
compatibleServices,
(services) => {
if (services.length && !services.some((service) => service.id === form.service_id)) {
form.service_id = services[0].id;
}
},
{ immediate: true },
);
</script>
<template>
<Head title="Attach Managed Service" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: 'Attach Managed Service',
},
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('environment-attachments.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Attach Managed Service</h2>
</div>
<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 v-for="service in compatibleServices" :key="service.id" :value="service.id">
{{ service.name }} · {{ service.type }}
</option>
</select>
<InputError :message="form.errors.service_id" />
</div>
<div class="rounded-md border bg-muted/30 p-3 text-sm">
<div class="font-medium">Generated {{ generatedSliceType }}</div>
<div class="mt-1 text-muted-foreground">{{ selectedService?.name ?? 'No compatible service' }} · {{ form.role.replace('_', ' ') }}</div>
</div>
<div class="grid gap-2">
<Label for="role">Role</Label>
<select id="role" v-model="form.role" class="h-9 rounded-md border border-input bg-transparent px-3 text-sm">
<option v-for="role in roles" :key="role" :value="role">
{{ role.replace('_', ' ') }}
</option>
</select>
<InputError :message="form.errors.role" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="name">Slice name</Label>
<Input id="name" v-model="form.name" type="text" placeholder="billing_api_production" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="env_prefix">Env prefix</Label>
<Input id="env_prefix" v-model="form.env_prefix" type="text" placeholder="READONLY" />
<InputError :message="form.errors.env_prefix" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.is_primary" type="checkbox" class="size-4" />
Primary attachment
</label>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing || !services.length">Attach</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
});
const form = useForm({
key: '',
value: '',
});
</script>
<template>
<Head title="Add Environment Variable" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: 'Add Variable',
},
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('environment-variables.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Add Environment Variable</h2>
</div>
<div class="grid gap-2">
<Label for="key">Key</Label>
<Input id="key" v-model="form.key" type="text" required placeholder="APP_DEBUG" />
<InputError :message="form.errors.key" />
</div>
<div class="grid gap-2">
<Label for="value">Value</Label>
<Input id="value" v-model="form.value" type="text" />
<InputError :message="form.errors.value" />
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">Save</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,195 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import { DatabaseIcon, GitBranchIcon, ListChecksIcon, PlusIcon, RocketIcon, ServerIcon, SettingsIcon } from 'lucide-vue-next';
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
});
</script>
<template>
<Head :title="`${environment.name} Environment`" />
<AppLayout
:breadcrumbs="[
{ title: 'Applications', href: route('applications.index', { organisation: $page.props.organisation.id }) },
{
title: application.name,
href: route('applications.show', { organisation: $page.props.organisation.id, application: application.id }),
},
{ title: 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>
<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>
<p class="mt-1 text-sm text-muted-foreground"><GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
@click="
router.post(
route('environment-deployments.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<RocketIcon class="size-4" />
Deploy
</Button>
<Button
variant="secondary"
@click="
router.post(
route('environment-migrations.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<ListChecksIcon class="size-4" />
Migrate
</Button>
<Button
:as="Link"
variant="secondary"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Attach
</Button>
</div>
</div>
<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>
</CardHeader>
<CardContent class="grid gap-3">
<div v-for="service in environment.services" :key="service.id" class="rounded-md border p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<ServerIcon class="size-4" />
<h3 class="font-semibold">{{ service.name }}</h3>
<Badge variant="outline">{{ service.type }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ service.replicas?.length ?? 0 }} replicas · {{ service.slices?.length ?? 0 }} slices ·
{{ service.status?.replace('-', ' ') }}
</p>
</div>
<Button
v-if="service.server_id"
:as="Link"
size="sm"
variant="secondary"
:href="
route('services.show', {
organisation: $page.props.organisation.id,
server: service.server_id,
service: service.id,
})
"
>
<SettingsIcon class="size-4" />
Open
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="operation in environment.operations" :key="operation.id" class="flex items-center justify-between rounded-md border p-3">
<span class="font-medium">{{ operation.kind.replace('_', ' ') }}</span>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">{{ operation.status.replace('_', ' ') }}</Badge>
</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" />
{{ attachment.role.replace('_', ' ') }}
</div>
<p class="mt-1 text-muted-foreground">{{ attachment.service?.name }} · {{ attachment.service_slice?.name ?? 'service level' }}</p>
</div>
</CardContent>
</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.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Add
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,53 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { CheckIcon, CircleIcon } from 'lucide-vue-next';
defineProps({
organisation: { type: Object, required: true },
steps: { type: Array, required: true },
nextStep: { type: Object, required: true },
});
</script>
<template>
<Head title="Onboarding" />
<AppLayout
:breadcrumbs="[
{ title: organisation.name, href: route('organisations.show', { organisation: organisation.id }) },
{ title: 'Onboarding' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-3xl font-bold tracking-tight">Onboarding</h2>
<p class="mt-1 text-sm text-muted-foreground">{{ organisation.name }}</p>
</div>
<Button :as="Link" :href="nextStep.href">{{ nextStep.label }}</Button>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="step in steps" :key="step.key">
<CardHeader>
<div class="flex items-center justify-between gap-3">
<CardTitle>{{ step.label }}</CardTitle>
<Badge :variant="step.complete ? 'success' : 'secondary'">
<CheckIcon v-if="step.complete" class="size-3" />
<CircleIcon v-else class="size-3" />
{{ step.complete ? 'Ready' : 'Open' }}
</Badge>
</div>
</CardHeader>
<CardContent>
<Button :as="Link" variant="secondary" :href="step.href" class="w-full">{{ step.label }}</Button>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link, WhenVisible } from '@inertiajs/vue3';
import { AppWindowIcon, ServerIcon, UserIcon } from 'lucide-vue-next';
import { AppWindowIcon, GitBranchIcon, ServerIcon, ShieldCheckIcon, UserIcon } from 'lucide-vue-next';
import { ref, watch } from 'vue';
defineProps({
@@ -15,6 +16,14 @@ defineProps({
type: Array,
required: false,
},
registries: {
type: Array,
required: false,
},
sourceProviders: {
type: Array,
required: false,
},
});
const tabValue = ref(new URL(window.location.href).hash?.replace('#', '') || 'dashboard');
@@ -92,6 +101,58 @@ watch(
</div>
</TabsContent>
<TabsContent value="settings">
<WhenVisible data="registries">
<template #fallback> Loading... </template>
<div class="mt-4 flex items-center justify-between gap-3">
<h3 class="text-2xl font-bold tracking-tight">Registries</h3>
<Button
:as="Link"
:href="
route('registries.create', {
organisation: organisation.id,
})
"
>
Add
</Button>
</div>
<div class="border-muted-background divide-y-muted-background mb-6 max-w-96 divide-y rounded-md border">
<div v-for="registry in registries" :key="registry.id" class="flex items-center gap-2 px-2 py-1">
<ShieldCheckIcon class="size-4 text-muted-foreground" />
{{ registry.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{ registry.type }}</span>
</div>
<div v-if="!registries?.length" class="px-2 py-1 text-sm text-muted-foreground">No registries configured</div>
</div>
</WhenVisible>
<WhenVisible data="sourceProviders">
<template #fallback> Loading... </template>
<div class="mb-6">
<div class="flex items-center justify-between gap-3">
<h3 class="text-2xl font-bold tracking-tight">Source Providers</h3>
<Button
:as="Link"
:href="
route('source-providers.create', {
organisation: organisation.id,
})
"
>
Add
</Button>
</div>
<div class="border-muted-background divide-y-muted-background max-w-96 divide-y rounded-md border">
<div v-for="sourceProvider in sourceProviders" :key="sourceProvider.id" class="flex items-center gap-2 px-2 py-1">
<GitBranchIcon class="size-4 text-muted-foreground" />
{{ sourceProvider.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{ sourceProvider.type }}</span>
</div>
<div v-if="!sourceProviders?.length" class="px-2 py-1 text-sm text-muted-foreground">
No source providers configured
</div>
</div>
</div>
</WhenVisible>
<WhenVisible data="providers">
<template #fallback> Loading... </template>
<h3 class="mt-4 text-2xl font-bold tracking-tight">Server Providers</h3>

View File

@@ -0,0 +1,90 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
registryTypes: {
type: Array,
required: true,
},
});
const form = useForm({
name: '',
type: 'generic',
url: '',
username: '',
password: '',
});
</script>
<template>
<Head title="Create Registry" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', {
organisation: $page.props.organisation.id,
}),
},
{
title: 'Create Registry',
},
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('registries.store', { organisation: $page.props.organisation.id }))"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Registry</h2>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" type="text" required placeholder="GHCR" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select id="type" v-model="form.type" class="h-9 rounded-md border border-input bg-transparent px-3 text-sm">
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType">
{{ registryType.replace('_', ' ') }}
</option>
</select>
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2">
<Label for="url">Registry URL</Label>
<Input id="url" v-model="form.url" type="text" required placeholder="ghcr.io/example" />
<InputError :message="form.errors.url" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="username">Username</Label>
<Input id="username" v-model="form.username" type="text" autocomplete="username" />
<InputError :message="form.errors.username" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" v-model="form.password" type="password" autocomplete="new-password" />
<InputError :message="form.errors.password" />
</div>
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">Create</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -54,7 +54,6 @@ const props = defineProps({
></Link>
</Card>
<div>@todo pagination</div>
</div>
</AppLayout>
</template>

View File

@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { useCycleList, useInterval } from '@vueuse/core';
import { DatabaseIcon, Layers2Icon, LoaderCircleIcon, PlusIcon } from 'lucide-vue-next';
import { DatabaseIcon, Layers2Icon, LoaderCircleIcon, PlusIcon, RefreshCwIcon } from 'lucide-vue-next';
import { ref, watch } from 'vue';
defineProps({
@@ -97,17 +97,34 @@ watch(counter, () => {
<Layers2Icon class="inline-block size-4" /> {{ service.slices?.length }} slices
</CardDescription>
</CardHeader>
<CardContent v-if="['postgres', 'valkey'].includes(service.type)">
<Button
:as="Link"
:href="
route('service-updates.create', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
})
"
size="xs"
variant="outline"
>
<RefreshCwIcon class="size-4" />
Update
</Button>
</CardContent>
</Card>
</div>
</div>
<div>
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Deployments</h3>
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
<Card>
<CardContent class="py-4">
<div v-for="deployment in server.service_deployments" class="flex gap-4">
<div class="w-48 leading-none">{{ deployment.target.name }}</div>
<div v-for="operation in server.service_operations" :key="operation.id" class="flex gap-4">
<div class="w-48 leading-none">{{ operation.target.name }}</div>
<div class="w-full space-y-4">
<div v-for="step in deployment.steps" class="flex items-center space-y-1">
<div v-for="step in operation.steps" :key="step.id" class="flex items-center space-y-1">
<div class="flex-1">
<div class="text-sm font-semibold leading-none">
{{ step.name ?? 'Unnamed Step' }}

View File

@@ -0,0 +1,68 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
const props = defineProps({
server: { type: Object, required: true },
service: { type: Object, required: true },
});
const form = useForm({
name: props.service.name,
desired_replicas: props.service.desired_replicas,
default_cpu_limit: props.service.default_cpu_limit,
default_memory_limit_mb: props.service.default_memory_limit_mb,
});
</script>
<template>
<Head :title="`Edit ${service.name}`" />
<AppLayout
:breadcrumbs="[
{ title: 'Servers', href: route('servers.index', { organisation: $page.props.organisation.id }) },
{ title: server.name, href: route('servers.show', { organisation: $page.props.organisation.id, server: server.id }) },
{ title: service.name, href: route('services.show', { organisation: $page.props.organisation.id, server: server.id, service: service.id }) },
{ title: 'Edit' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.put(route('services.update', { organisation: $page.props.organisation.id, server: server.id, service: service.id }))"
>
<h2 class="text-3xl font-bold tracking-tight">Edit {{ service.name }}</h2>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" type="text" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-2">
<Label for="desired_replicas">Replicas</Label>
<Input id="desired_replicas" v-model="form.desired_replicas" type="number" min="0" max="25" />
<InputError :message="form.errors.desired_replicas" />
</div>
<div class="grid gap-2">
<Label for="default_cpu_limit">CPU</Label>
<Input id="default_cpu_limit" v-model="form.default_cpu_limit" type="number" min="0.125" max="64" step="0.125" />
<InputError :message="form.errors.default_cpu_limit" />
</div>
<div class="grid gap-2">
<Label for="default_memory_limit_mb">Memory MB</Label>
<Input id="default_memory_limit_mb" v-model="form.default_memory_limit_mb" type="number" min="64" max="1048576" />
<InputError :message="form.errors.default_memory_limit_mb" />
</div>
</div>
<div class="flex justify-end">
<Button type="submit" :disabled="form.processing">Save</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,84 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { PencilIcon } from 'lucide-vue-next';
const props = defineProps({
server: { type: Object, required: true },
service: { type: Object, required: true },
});
</script>
<template>
<Head :title="service.name" />
<AppLayout
:breadcrumbs="[
{ title: 'Servers', href: route('servers.index', { organisation: $page.props.organisation.id }) },
{ title: server.name, href: route('servers.show', { organisation: $page.props.organisation.id, server: server.id }) },
{ title: service.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>
<div class="flex items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ service.name }}</h2>
<Badge variant="outline">{{ service.type }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ service.category }} · {{ service.version }}</p>
</div>
<Button
:as="Link"
variant="secondary"
:href="route('services.edit', { organisation: $page.props.organisation.id, server: server.id, service: service.id })"
>
<PencilIcon class="size-4" />
Edit
</Button>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Replicas</CardTitle>
<CardDescription>Desired: {{ service.desired_replicas }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="replica in service.replicas" :key="replica.id" class="rounded-md border p-3 text-sm">
<div class="font-medium">{{ replica.container_name }}</div>
<div class="text-muted-foreground">{{ replica.status }} · {{ replica.health_status }}</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Slices</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="slice in service.slices" :key="slice.id" class="rounded-md border p-3 text-sm">
<div class="font-medium">{{ slice.name }}</div>
<div class="text-muted-foreground">{{ slice.type }}</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="operation in service.operations" :key="operation.id" class="flex items-center justify-between rounded-md border p-3 text-sm">
<span>{{ operation.kind.replace('_', ' ') }}</span>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">{{ operation.status.replace('_', ' ') }}</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,102 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { AlertTriangleIcon } from 'lucide-vue-next';
const props = defineProps({
server: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
backupAvailable: {
type: Boolean,
required: true,
},
});
const form = useForm({
image_digest: props.service.available_image_digest ?? props.service.current_image_digest ?? '',
backup_requested: false,
});
</script>
<template>
<Head :title="`Update ${service.name}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Servers',
href: route('servers.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: server.name,
href: route('servers.show', {
organisation: $page.props.organisation.id,
server: server.id,
}),
},
{
title: service.name,
},
{
title: 'Update',
},
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<Card>
<CardHeader>
<div class="flex items-center gap-3">
<AlertTriangleIcon class="size-5 text-amber-600" />
<CardTitle>Stateful service update</CardTitle>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
This update stops the running container, keeps the named Docker volume in place, starts the new image, and then runs a health check.
</div>
<div class="grid gap-2">
<Label for="image_digest">Image digest</Label>
<Input id="image_digest" v-model="form.image_digest" placeholder="sha256:..." />
<InputError :message="form.errors.image_digest" />
</div>
<label v-if="backupAvailable" class="flex items-center gap-2 text-sm">
<input v-model="form.backup_requested" type="checkbox" class="size-4 rounded border-input" />
Run configured backup first
</label>
<div class="flex justify-end">
<Button
:disabled="form.processing"
@click="
form.post(
route('service-updates.store', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
}),
)
"
>
Create update operation
</Button>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
sourceProviderTypes: {
type: Array,
required: true,
},
});
const form = useForm({
name: '',
type: 'generic_git',
url: '',
});
</script>
<template>
<Head title="Create Source Provider" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', {
organisation: $page.props.organisation.id,
}),
},
{
title: 'Create Source Provider',
},
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('source-providers.store', { organisation: $page.props.organisation.id }))"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Source Provider</h2>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" type="text" required placeholder="GitHub" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select id="type" v-model="form.type" class="h-9 rounded-md border border-input bg-transparent px-3 text-sm">
<option v-for="sourceProviderType in sourceProviderTypes" :key="sourceProviderType" :value="sourceProviderType">
{{ sourceProviderType.replace('_', ' ') }}
</option>
</select>
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2">
<Label for="url">Base URL</Label>
<Input id="url" v-model="form.url" type="text" placeholder="https://gitea.example.com" />
<InputError :message="form.errors.url" />
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">Create</Button>
</div>
</form>
</AppLayout>
</template>