Implement Keystone environment deployments
This commit is contained in:
9
resources/js/enums/BuildArtifactStatus.js
Normal file
9
resources/js/enums/BuildArtifactStatus.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"PENDING": "pending",
|
||||
"BUILDING": "building",
|
||||
"AVAILABLE": "available",
|
||||
"FAILED": "failed"
|
||||
}
|
||||
|
||||
8
resources/js/enums/BuildStrategy.js
Normal file
8
resources/js/enums/BuildStrategy.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"TARGET_SERVER": "target_server",
|
||||
"DEDICATED_BUILDER": "dedicated_builder",
|
||||
"EXTERNAL_REGISTRY": "external_registry"
|
||||
}
|
||||
|
||||
9
resources/js/enums/DeployPolicy.js
Normal file
9
resources/js/enums/DeployPolicy.js
Normal 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"
|
||||
}
|
||||
|
||||
11
resources/js/enums/EnvironmentAttachmentRole.js
Normal file
11
resources/js/enums/EnvironmentAttachmentRole.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"DATABASE": "database",
|
||||
"CACHE": "cache",
|
||||
"QUEUE": "queue",
|
||||
"STORAGE": "storage",
|
||||
"GATEWAY": "gateway",
|
||||
"CUSTOM": "custom"
|
||||
}
|
||||
|
||||
8
resources/js/enums/EnvironmentVariableSource.js
Normal file
8
resources/js/enums/EnvironmentVariableSource.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"USER": "user",
|
||||
"MANAGED_ATTACHMENT": "managed_attachment",
|
||||
"SYSTEM": "system"
|
||||
}
|
||||
|
||||
14
resources/js/enums/OperationKind.js
Normal file
14
resources/js/enums/OperationKind.js
Normal 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"
|
||||
}
|
||||
|
||||
9
resources/js/enums/RegistryType.js
Normal file
9
resources/js/enums/RegistryType.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"GENERIC": "generic",
|
||||
"GITEA": "gitea",
|
||||
"GHCR": "ghcr",
|
||||
"DOCKER_HUB": "docker_hub"
|
||||
}
|
||||
|
||||
7
resources/js/enums/SchedulerMode.js
Normal file
7
resources/js/enums/SchedulerMode.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"SINGLE": "single",
|
||||
"EVERY_REPLICA": "every_replica"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
8
resources/js/enums/ServiceEndpointScope.js
Normal file
8
resources/js/enums/ServiceEndpointScope.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"DOCKER_NETWORK": "docker_network",
|
||||
"PRIVATE_NETWORK": "private_network",
|
||||
"PUBLIC": "public"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
8
resources/js/enums/SourceProviderType.js
Normal file
8
resources/js/enums/SourceProviderType.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
"GITEA": "gitea",
|
||||
"GITHUB": "github",
|
||||
"GENERIC_GIT": "generic_git"
|
||||
}
|
||||
|
||||
72
resources/js/pages/applications/Create.vue
Normal file
72
resources/js/pages/applications/Create.vue
Normal 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>
|
||||
@@ -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="
|
||||
|
||||
@@ -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 }} • {{ 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>
|
||||
|
||||
164
resources/js/pages/environment-attachments/Create.vue
Normal file
164
resources/js/pages/environment-attachments/Create.vue
Normal 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>
|
||||
82
resources/js/pages/environment-variables/Create.vue
Normal file
82
resources/js/pages/environment-variables/Create.vue
Normal 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>
|
||||
195
resources/js/pages/environments/Show.vue
Normal file
195
resources/js/pages/environments/Show.vue
Normal 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>
|
||||
53
resources/js/pages/onboarding/Show.vue
Normal file
53
resources/js/pages/onboarding/Show.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
90
resources/js/pages/registries/Create.vue
Normal file
90
resources/js/pages/registries/Create.vue
Normal 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>
|
||||
@@ -54,7 +54,6 @@ const props = defineProps({
|
||||
></Link>
|
||||
</Card>
|
||||
|
||||
<div>@todo pagination</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
68
resources/js/pages/services/Edit.vue
Normal file
68
resources/js/pages/services/Edit.vue
Normal 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>
|
||||
84
resources/js/pages/services/Show.vue
Normal file
84
resources/js/pages/services/Show.vue
Normal 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>
|
||||
102
resources/js/pages/services/updates/Create.vue
Normal file
102
resources/js/pages/services/updates/Create.vue
Normal 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>
|
||||
74
resources/js/pages/source-providers/Create.vue
Normal file
74
resources/js/pages/source-providers/Create.vue
Normal 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>
|
||||
Reference in New Issue
Block a user