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

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

View File

@@ -22,7 +22,16 @@ import UserMenuContent from "@/components/UserMenuContent.vue";
import { getInitials } from "@/composables/useInitials";
import type { BreadcrumbItem, NavItem } from "@/types";
import { Link, usePage } from "@inertiajs/vue3";
import { AppWindowIcon, BoltIcon, Menu, Search, ServerIcon } from "lucide-vue-next";
import {
AppWindowIcon,
BoltIcon,
BoxesIcon,
ClipboardListIcon,
Menu,
Search,
ServerIcon,
WorkflowIcon,
} from "lucide-vue-next";
import { computed } from "vue";
interface Props {
@@ -36,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage();
const auth = computed(() => page.props.auth);
const isCurrentRoute = computed(() => (url: string) => page.url === url);
const isCurrentRoute = computed(() => (url: string) => page.url === url || page.url.startsWith(`${url}/`));
const activeItemStyles = computed(
() => (url: string) =>
@@ -54,20 +63,31 @@ const mainNavItems: NavItem[] = [
];
if (page.props.organisation) {
const organisationId = page.props?.organisation?.id;
mainNavItems.push({
title: page.props.organisation.name,
href: new URL(
route("organisations.show", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
icon: BoltIcon,
});
mainNavItems.push({
title: "Environments",
href: new URL(
route("environments.index", {
organisation: organisationId,
}),
).pathname,
icon: BoxesIcon,
});
mainNavItems.push({
title: "Applications",
href: new URL(
route("applications.index", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
icon: AppWindowIcon,
@@ -76,11 +96,38 @@ if (page.props.organisation) {
title: "Servers",
href: new URL(
route("servers.index", {
organisation: page.props?.organisation?.id,
organisation: organisationId,
}),
).pathname,
icon: ServerIcon,
});
mainNavItems.push({
title: "Operations",
href: new URL(
route("operations.index", {
organisation: organisationId,
}),
).pathname,
icon: WorkflowIcon,
});
if (
page.props.organisation.providers_count === 0 ||
page.props.organisation.source_providers_count === 0 ||
page.props.organisation.registries_count === 0 ||
page.props.organisation.servers_count === 0 ||
page.props.organisation.applications_count === 0
) {
mainNavItems.push({
title: "Onboarding",
href: new URL(
route("onboarding.show", {
organisation: organisationId,
}),
).pathname,
icon: ClipboardListIcon,
});
}
}
const rightNavItems: NavItem[] = [

View File

@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { type NavItem } from "@/types";
import { Link, usePage } from "@inertiajs/vue3";
import { LayoutGrid, Server } from "lucide-vue-next";
import { AppWindow, Boxes, ClipboardList, LayoutGrid, Server, Workflow } from "lucide-vue-next";
import AppLogo from "./AppLogo.vue";
const mainNavItems: NavItem[] = [
@@ -27,6 +27,20 @@ const mainNavItems: NavItem[] = [
const organisation = usePage().props.organisation;
if (organisation) {
mainNavItems.push({
title: "Environments",
href: route("environments.index", {
organisation: organisation.id,
}),
icon: Boxes,
});
mainNavItems.push({
title: "Applications",
href: route("applications.index", {
organisation: organisation.id,
}),
icon: AppWindow,
});
mainNavItems.push({
title: "Servers",
href: route("servers.index", {
@@ -34,6 +48,29 @@ if (organisation) {
}),
icon: Server,
});
mainNavItems.push({
title: "Operations",
href: route("operations.index", {
organisation: organisation.id,
}),
icon: Workflow,
});
if (
organisation.providers_count === 0 ||
organisation.source_providers_count === 0 ||
organisation.registries_count === 0 ||
organisation.servers_count === 0 ||
organisation.applications_count === 0
) {
mainNavItems.push({
title: "Onboarding",
href: route("onboarding.show", {
organisation: organisation.id,
}),
icon: ClipboardList,
});
}
}
const footerNavItems: NavItem[] = [];

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Link } from "@inertiajs/vue3";
import { GitCommitIcon } from "lucide-vue-next";
import { ref } from "vue";
defineProps<{
operations: Record<string, any>[];
showTarget?: boolean;
}>();
const selectedStep = ref<Record<string, any> | null>(null);
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
const targetLabel = (target?: Record<string, any> | null): string => {
if (!target) {
return "Unknown target";
}
return target.name ?? target.hostname ?? `#${target.id}`;
};
</script>
<template>
<div class="grid gap-3">
<div
v-for="operation in operations"
:key="operation.id"
class="rounded-md border p-3 text-sm"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<GitCommitIcon class="size-4 text-muted-foreground" />
<Link
:href="
route('operations.show', {
organisation: $page.props.organisation.id,
operation: operation.id,
})
"
class="font-medium hover:underline"
>
{{ label(operation.kind) }}
</Link>
<Badge variant="outline">{{ operation.hash }}</Badge>
<Badge
:variant="operation.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(operation.status) }}
</Badge>
</div>
<p v-if="showTarget" class="mt-1 text-muted-foreground">
Target: {{ targetLabel(operation.target) }}
</p>
</div>
<div class="text-xs text-muted-foreground">
{{ operation.steps_count ?? operation.steps?.length ?? 0 }} steps
<span v-if="operation.children_count ?? operation.children?.length">
· {{ operation.children_count ?? operation.children?.length }} child ops
</span>
</div>
</div>
<div v-if="operation.steps?.length" class="mt-3 grid gap-2 border-l pl-3">
<div
v-for="step in operation.steps"
:key="step.id"
class="flex items-start justify-between gap-3"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
<Badge
:variant="step.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(step.status) }}
</Badge>
</div>
<pre
v-if="step.error_logs_excerpt || step.logs_excerpt"
class="mt-1 max-h-20 overflow-hidden whitespace-pre-wrap text-xs text-muted-foreground"
>{{ step.error_logs_excerpt ?? step.logs_excerpt }}</pre
>
</div>
<Button
v-if="step.logs || step.error_logs"
size="xs"
variant="link"
@click="selectedStep = step"
>
Logs
</Button>
</div>
</div>
<div v-if="operation.children?.length" class="mt-3 grid gap-2 border-l pl-3">
<div
v-for="child in operation.children"
:key="child.id"
class="rounded-md bg-muted/40 p-2"
>
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
route('operations.show', {
organisation: $page.props.organisation.id,
operation: child.id,
})
"
class="font-medium hover:underline"
>
{{ label(child.kind) }}
</Link>
<Badge
:variant="child.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(child.status) }}
</Badge>
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
</div>
</div>
</div>
</div>
<div v-if="operations.length === 0" class="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
No operations recorded yet.
</div>
</div>
<Dialog :open="!!selectedStep" @update:open="($event) => (!$event ? (selectedStep = null) : null)">
<DialogContent class="md:max-w-3xl">
<DialogHeader>
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
</DialogHeader>
<section v-if="selectedStep?.logs">
<h3 class="text-sm font-medium">Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.logs }}</pre>
</section>
<section v-if="selectedStep?.error_logs">
<h3 class="text-sm font-medium">Error Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.error_logs }}</pre>
</section>
</DialogContent>
</Dialog>
</template>

View File

@@ -5,12 +5,11 @@ import { type BreadcrumbItem } from "@/types";
import { Head, Link } from "@inertiajs/vue3";
import { ChevronRightIcon } from "lucide-vue-next";
defineProps({
organisations: {
type: Array,
required: true,
},
});
defineProps<{
organisations: Record<string, any>[];
recentOperations: Record<string, any>[];
unhealthyServices: Record<string, any>[];
}>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -24,23 +23,80 @@ const breadcrumbs: BreadcrumbItem[] = [
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col items-center gap-4 rounded-xl p-4">
<Card class="w-80">
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader class="border-b-muted-background border-b">
<CardTitle>Your Organisation</CardTitle>
<CardDescription> Select an organisation to view its details. </CardDescription>
<CardTitle>Organisations</CardTitle>
<CardDescription>Select an organisation to view its environments.</CardDescription>
</CardHeader>
<CardContent class="divide-y-muted-foreground divide-y p-0">
<Link
v-for="organisation in organisations"
:key="organisation.id"
:href="route('organisations.show', { organisation: organisation.id })"
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
>
<div>{{ organisation.name }}</div>
<div>
<div class="font-medium">{{ organisation.name }}</div>
<div class="text-sm text-muted-foreground">
{{ organisation.applications_count }} applications ·
{{ organisation.servers_count }} servers ·
{{ organisation.services_count }} services
</div>
</div>
<ChevronRightIcon class="size-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Unhealthy services</CardTitle>
<CardDescription>Services that need attention across your organisations.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="service in unhealthyServices"
:key="service.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ service.name }}</div>
<div class="text-muted-foreground">{{ service.status }}</div>
</div>
<div
v-if="unhealthyServices.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No unhealthy services.
</div>
</CardContent>
</Card>
<Card class="lg:col-span-3">
<CardHeader>
<CardTitle>Recent operations</CardTitle>
<CardDescription>Latest service operations across your organisations.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="operation in recentOperations"
:key="operation.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div>
<div class="font-medium">{{ operation.kind.replace("_", " ") }}</div>
<div class="text-muted-foreground">{{ operation.hash }}</div>
</div>
<div class="text-muted-foreground">{{ operation.status.replace("-", " ") }}</div>
</div>
<div
v-if="recentOperations.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No operations recorded.
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -1,13 +1,21 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { Head, Link, useForm } from "@inertiajs/vue3";
defineProps<{
sourceProviders: Record<string, any>[];
repositoryTypes: Record<string, string>;
}>();
const form = useForm({
name: "",
source_provider_id: "",
repository_type: "git",
repository_url: "",
default_branch: "main",
environment_name: "production",
@@ -31,7 +39,7 @@ const form = useForm({
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
class="flex h-full max-w-3xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('applications.store', { organisation: $page.props.organisation.id }),
@@ -42,6 +50,67 @@ const form = useForm({
<h2 class="text-3xl font-bold tracking-tight">Create Application</h2>
</div>
<Card>
<CardHeader>
<CardTitle>Repository access</CardTitle>
<CardDescription>
Keystone will generate a deploy key after creation.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 text-sm">
<div class="grid gap-2">
<Label for="repository_type">Repository type</Label>
<select
id="repository_type"
v-model="form.repository_type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option
v-for="(type, key) in repositoryTypes"
:key="key"
:value="type"
>
{{ type }}
</option>
</select>
<InputError :message="form.errors.repository_type" />
</div>
<div v-if="sourceProviders.length" class="grid gap-2">
<Label for="source_provider_id">Source provider</Label>
<select
id="source_provider_id"
v-model="form.source_provider_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No provider</option>
<option
v-for="provider in sourceProviders"
:key="provider.id"
:value="provider.id"
>
{{ provider.name }} · {{ provider.type }}
</option>
</select>
<InputError :message="form.errors.source_provider_id" />
</div>
<div v-else class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3">
<span class="text-muted-foreground">
No source provider is configured yet. SSH URLs still work, but adding a
provider documents which Git host this repository belongs to.
</span>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="route('source-providers.create', { organisation: $page.props.organisation.id })"
>
Add provider
</Button>
</div>
</CardContent>
</Card>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
repositoryTypes: Record<string, string>;
sourceProviders: Record<string, any>[];
}>();
const form = useForm({
name: props.application.name,
source_provider_id: props.application.source_provider_id ?? "",
repository_type: props.application.repository_type ?? "git",
repository_url: props.application.repository_url,
default_branch: props.application.default_branch,
});
const destroyApplication = (): void => {
if (!window.confirm(`Delete ${props.application.name}? This removes its environments too.`)) {
return;
}
router.delete(
route("applications.destroy", {
organisation: props.application.organisation_id,
application: props.application.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${application.name}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{ title: 'Edit' },
]"
>
<form
class="flex h-full max-w-3xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('applications.update', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Application</h2>
<p class="mt-1 text-sm text-muted-foreground">
Update repository metadata used when resolving deploy targets.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Repository</CardTitle>
</CardHeader>
<CardContent class="grid gap-4">
<div class="grid gap-2">
<Label for="repository_type">Repository type</Label>
<select
id="repository_type"
v-model="form.repository_type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option
v-for="(type, key) in repositoryTypes"
:key="key"
:value="type"
>
{{ type }}
</option>
</select>
<InputError :message="form.errors.repository_type" />
</div>
<div class="grid gap-2">
<Label for="source_provider_id">Source provider</Label>
<select
id="source_provider_id"
v-model="form.source_provider_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No provider</option>
<option
v-for="provider in sourceProviders"
:key="provider.id"
:value="provider.id"
>
{{ provider.name }} · {{ provider.type }}
</option>
</select>
<InputError :message="form.errors.source_provider_id" />
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="repository_url">Repository SSH URL</Label>
<Input id="repository_url" v-model="form.repository_url" required />
<InputError :message="form.errors.repository_url" />
</div>
<div class="grid gap-2">
<Label for="default_branch">Default branch</Label>
<Input id="default_branch" v-model="form.default_branch" required />
<InputError :message="form.errors.default_branch" />
</div>
</CardContent>
</Card>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroyApplication">
Delete application
</Button>
<Button type="submit" :disabled="form.processing">Save changes</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -1,15 +1,13 @@
<script setup>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PlusIcon } from "lucide-vue-next";
const props = defineProps({
applications: {
type: [Object, null],
required: true,
},
});
defineProps<{
applications: Record<string, any>[];
}>();
</script>
<template>
@@ -26,7 +24,12 @@ const props = defineProps({
]"
>
<div class="flex items-center justify-between gap-3 p-4">
<h2 class="text-3xl font-bold tracking-tight">Applications</h2>
<div>
<h2 class="text-3xl font-bold tracking-tight">Applications</h2>
<p class="mt-1 text-sm text-muted-foreground">
Source repositories and their deployment environments.
</p>
</div>
<div>
<Button
:as="Link"
@@ -36,6 +39,7 @@ const props = defineProps({
})
"
>
<PlusIcon class="size-4" />
Create
</Button>
</div>
@@ -43,7 +47,7 @@ const props = defineProps({
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="application in applications"
:key="`application{$applications.id}`"
:key="application.id"
class="relative w-full"
>
<CardHeader>
@@ -62,6 +66,28 @@ const props = defineProps({
class="absolute inset-0"
></Link>
</Card>
<Card v-if="applications.length === 0" class="md:col-span-2 lg:col-span-3">
<CardHeader>
<CardTitle>No applications yet</CardTitle>
<CardDescription>
Create an application to add the first environment, deploy key, and runtime
services.
</CardDescription>
<div>
<Button
:as="Link"
:href="
route('applications.create', {
organisation: $page.props.organisation.id,
})
"
>
<PlusIcon class="size-4" />
Create application
</Button>
</div>
</CardHeader>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -2,22 +2,29 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import {
BoxesIcon,
ChevronDownIcon,
ExternalLinkIcon,
GitBranchIcon,
KeyRoundIcon,
PencilIcon,
PlusIcon,
RocketIcon,
} from "lucide-vue-next";
const props = defineProps({
application: {
type: Object,
required: true,
},
});
defineProps<{
application: Record<string, any>;
deploymentRequirements: Record<string, any>;
}>();
</script>
<template>
@@ -30,45 +37,122 @@ const props = defineProps({
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: props.application.name,
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: props.application.id,
application: application.id,
}),
},
]"
>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<div class="flex items-center gap-3">
<h2 class="text-3xl font-bold tracking-tight">{{ application.name }}</h2>
<div>
<h2 class="text-3xl font-bold tracking-tight">{{ application.name }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ application.source_provider?.name ?? "No source provider" }} ·
{{ application.repository_type }}
</p>
</div>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('applications.edit', {
organisation: $page.props.organisation.id,
application: application.id,
})
"
>
<PencilIcon class="size-4" />
Edit
</Button>
</div>
<Card v-if="application.deploy_key_public && !application.deploy_key_installed_at">
<Card v-if="application.deploy_key_public">
<CardHeader>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-3">
<div class="flex items-center gap-2">
<KeyRoundIcon class="size-4" />
<CardTitle>Repository Deploy Key</CardTitle>
<Badge
:variant="
application.deploy_key_installed_at ? 'success' : 'secondary'
"
>
{{
application.deploy_key_installed_at
? "verified"
: "not verified"
}}
</Badge>
</div>
<div
v-if="application.deploy_key_fingerprint"
class="text-sm text-muted-foreground"
>
Fingerprint: {{ application.deploy_key_fingerprint }}
</div>
<pre
class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs"
>{{ application.deploy_key_public }}</pre
>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<Button
variant="secondary"
@click="
router.post(
route('applications.deploy-key.rotate', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
"
>
<KeyRoundIcon class="size-4" />
Rotate key
</Button>
<Button
@click="
router.post(
route('applications.verify-repository', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
"
>
<GitBranchIcon class="size-4" />
Verify access
</Button>
</div>
</div>
</CardHeader>
</Card>
<Card v-if="deploymentRequirements.registryRequired">
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Registry required before deployment</CardTitle>
<CardDescription>
This organisation has {{ deploymentRequirements.serverCount }}
servers and no registry. Multi-server deployments need a registry
so every server can pull the same build artifact.
</CardDescription>
</div>
<Button
class="shrink-0"
@click="
router.post(
route('applications.verify-repository', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
:as="Link"
:href="
route('registries.create', {
organisation: $page.props.organisation.id,
})
"
>
<GitBranchIcon class="size-4" />
Verify
Configure registry
</Button>
</div>
</CardHeader>
@@ -77,6 +161,19 @@ const props = defineProps({
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-2xl font-semibold tracking-tight">Environments</h3>
<Button
:as="Link"
size="sm"
:href="
route('environments.create', {
organisation: $page.props.organisation.id,
application: application.id,
})
"
>
<PlusIcon class="size-4" />
Add environment
</Button>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card
@@ -103,6 +200,38 @@ const props = defineProps({
Branch: {{ environment.branch }} &bull;
{{ environment.services?.length ?? 0 }} services
</CardDescription>
<div class="mt-3 grid gap-1 text-sm text-muted-foreground">
<div>
Last deploy:
{{
environment.operations?.[0]?.finished_at ??
environment.operations?.[0]?.created_at ??
"never"
}}
</div>
<div>
Current commit:
{{
environment.services?.find(
(service) => service.desired_revision,
)?.desired_revision ?? "unknown"
}}
</div>
<div>
Current image:
{{
environment.services?.find(
(service) =>
service.current_image_digest ||
service.available_image_digest,
)?.current_image_digest ??
environment.services?.find(
(service) => service.available_image_digest,
)?.available_image_digest ??
"unknown"
}}
</div>
</div>
<div
v-if="environment.variables?.length"
class="mt-3 flex flex-wrap gap-2"
@@ -120,7 +249,7 @@ const props = defineProps({
</Badge>
</div>
</div>
<div class="flex shrink-0 gap-2">
<div class="flex shrink-0 flex-wrap gap-2">
<Button
:as="Link"
size="xs"
@@ -138,6 +267,12 @@ const props = defineProps({
</Button>
<Button
size="xs"
:disabled="deploymentRequirements.registryRequired"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
@click="
router.post(
route('environment-deployments.store', {
@@ -151,63 +286,93 @@ const props = defineProps({
<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>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button size="xs" variant="secondary">
More
<ChevronDownIcon class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
:as="Link"
:href="
route('environments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Settings
</DropdownMenuItem>
<DropdownMenuItem
@click="
router.post(
route('environment-migrations.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
Migrate
</DropdownMenuItem>
<DropdownMenuItem
:as="Link"
:href="
route('environment-variables.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Variables
</DropdownMenuItem>
<DropdownMenuItem
@click="
router.post(
route('environment-workers.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
Add worker
</DropdownMenuItem>
<DropdownMenuItem
:as="Link"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Attach service
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
v-if="environment.build_artifacts?.length"
class="mt-4 grid gap-2 rounded-md bg-muted/40 p-3 text-sm"
>
<div class="font-medium">Recent builds</div>
<div
v-for="artifact in environment.build_artifacts"
:key="artifact.id"
class="flex flex-wrap items-center gap-2 text-muted-foreground"
>
<Badge variant="outline">{{ artifact.status }}</Badge>
<span>{{ artifact.commit_sha }}</span>
<span v-if="artifact.image_digest">{{ artifact.image_digest }}</span>
</div>
</div>
</CardHeader>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
artifacts: Record<string, any>;
}>();
</script>
<template>
<Head :title="`${environment.name} Builds`" />
<AppLayout
:breadcrumbs="[
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Builds' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div>
<h2 class="text-3xl font-bold tracking-tight">Build Artifacts</h2>
<p class="mt-1 text-sm text-muted-foreground">
Planned and built images for this environment.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Artifacts</CardTitle>
<CardDescription>{{ artifacts.data.length }} shown</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<Link
v-for="artifact in artifacts.data"
:key="artifact.id"
:href="
route('build-artifacts.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
artifact: artifact.id,
})
"
class="rounded-md border p-3 text-sm hover:bg-muted/50"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{{ artifact.status }}</Badge>
<span class="font-medium">{{ artifact.commit_sha }}</span>
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
</div>
<p class="mt-1 text-muted-foreground">
{{ artifact.registry_ref ?? "No registry ref" }}
</p>
</Link>
<div
v-if="artifacts.data.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No build artifacts recorded.
</div>
</CardContent>
</Card>
<div v-if="artifacts.links?.length > 3" class="flex flex-wrap gap-2">
<Button
v-for="link in artifacts.links"
:key="link.label"
:as="link.url ? Link : 'button'"
:href="link.url ?? undefined"
size="sm"
:variant="link.active ? 'default' : 'secondary'"
:disabled="!link.url"
v-html="link.label"
/>
</div>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head } from "@inertiajs/vue3";
defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
artifact: Record<string, any>;
}>();
</script>
<template>
<Head :title="artifact.commit_sha" />
<AppLayout
:breadcrumbs="[
{
title: 'Builds',
href: route('build-artifacts.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: artifact.commit_sha },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ artifact.commit_sha }}</h2>
<Badge variant="outline">{{ artifact.status }}</Badge>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Image</CardTitle>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<div>Tag: {{ artifact.image_tag }}</div>
<div>Digest: {{ artifact.image_digest ?? "not available" }}</div>
<div>Registry: {{ artifact.registry_ref ?? "not pushed" }}</div>
<div>Built by service: {{ artifact.built_by_service?.name ?? "none" }}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(artifact.metadata ?? {}, null, 2) }}</pre>
</CardContent>
</Card>
</div>
<Card v-if="artifact.built_by_operation">
<CardHeader>
<CardTitle>Build Operation</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="[artifact.built_by_operation]" />
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -7,24 +7,13 @@ import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
services: {
type: Array,
required: true,
},
roles: {
type: Array,
required: true,
},
});
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
services: Record<string, any>[];
roles: string[];
compatibility: Record<string, string[]>;
}>();
const form = useForm({
service_id: props.services[0]?.id ?? null,
@@ -32,18 +21,16 @@ const form = useForm({
name: "",
env_prefix: "",
is_primary: true,
domain: "",
path_prefix: "/",
tls_enabled: true,
});
const compatibleServices = computed(() => {
const roleTypes = {
database: ["postgres"],
cache: ["valkey"],
queue: ["valkey"],
gateway: ["caddy"],
};
return props.services.filter((service) =>
(roleTypes[form.role] ?? props.services.map((item) => item.type)).includes(service.type),
(props.compatibility[form.role] ?? props.services.map((item) => item.type)).includes(
service.type,
),
);
});
@@ -66,6 +53,34 @@ const generatedSliceType = computed(() => {
return "service link";
});
const envPrefix = computed(() => form.env_prefix || form.role.toUpperCase());
const variablePreview = computed(() => {
if (form.role === "database") {
return [
`${envPrefix.value}_HOST`,
`${envPrefix.value}_PORT`,
`${envPrefix.value}_DATABASE`,
`${envPrefix.value}_USERNAME`,
`${envPrefix.value}_PASSWORD`,
];
}
if (["cache", "queue"].includes(form.role)) {
return [
`${envPrefix.value}_HOST`,
`${envPrefix.value}_PORT`,
`${envPrefix.value}_DATABASE`,
`${envPrefix.value}_PASSWORD`,
];
}
if (form.role === "gateway") {
return ["APP_URL", "KEYSTONE_ROUTE_HOST", "KEYSTONE_ROUTE_PORT", "KEYSTONE_ROUTE_TLS"];
}
return [`${envPrefix.value}_HOST`, `${envPrefix.value}_PORT`];
});
watch(
compatibleServices,
(services) => {
@@ -142,6 +157,19 @@ watch(
</div>
</div>
<div class="rounded-md border bg-muted/30 p-3 text-sm">
<div class="font-medium">Environment variables preview</div>
<div class="mt-2 flex flex-wrap gap-2">
<code
v-for="variable in variablePreview"
:key="variable"
class="rounded bg-background px-2 py-1 text-xs"
>
{{ variable }}
</code>
</div>
</div>
<div class="grid gap-2">
<Label for="role">Role</Label>
<select
@@ -180,9 +208,31 @@ watch(
</div>
</div>
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3 md:grid-cols-3">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" type="text" placeholder="app.example.com" />
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" type="text" placeholder="/" />
<InputError :message="form.errors.path_prefix" />
</div>
<label class="flex items-center gap-2 pt-7 text-sm">
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
TLS enabled
</label>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.is_primary" type="checkbox" class="size-4" />
Primary attachment
<span>
Primary attachment
<span class="block text-muted-foreground">
Primary attachments provide the default unprefixed variables for this role.
</span>
</span>
</label>
<div class="flex items-center justify-end">

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
attachment: Record<string, any>;
roles: string[];
}>();
const form = useForm({
role: props.attachment.role,
env_prefix: props.attachment.env_prefix ?? "",
is_primary: Boolean(props.attachment.is_primary),
domain: props.attachment.service_slice?.config?.domain ?? "",
path_prefix: props.attachment.service_slice?.config?.path_prefix ?? "/",
tls_enabled: props.attachment.service_slice?.config?.tls_enabled ?? true,
certificate_status: props.attachment.service_slice?.config?.certificate_status ?? "",
});
const detach = (): void => {
if (!window.confirm("Detach this managed service?")) {
return;
}
router.delete(
route("environment-attachments.destroy", {
organisation: props.application.organisation_id,
application: props.application.id,
environment: props.environment.id,
attachment: props.attachment.id,
}),
);
};
</script>
<template>
<Head title="Edit Attachment" />
<AppLayout
:breadcrumbs="[
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Edit Attachment' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('environment-attachments.update', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
attachment: attachment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Attachment</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ attachment.service?.name }} ·
{{ attachment.service_slice?.name ?? "service level" }}
</p>
</div>
<div class="grid gap-2">
<Label for="role">Role</Label>
<select
id="role"
v-model="form.role"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="role in roles" :key="role" :value="role">
{{ role.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.role" />
</div>
<div class="grid gap-2">
<Label for="env_prefix">Env prefix</Label>
<Input id="env_prefix" v-model="form.env_prefix" placeholder="READONLY" />
<InputError :message="form.errors.env_prefix" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.is_primary" type="checkbox" class="size-4" />
Primary attachment
</label>
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" />
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" />
<InputError :message="form.errors.path_prefix" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
TLS enabled
</label>
<InputError :message="form.errors.tls_enabled" />
<div class="grid gap-2">
<Label for="certificate_status">Certificate status</Label>
<Input
id="certificate_status"
v-model="form.certificate_status"
placeholder="pending"
/>
<InputError :message="form.errors.certificate_status" />
</div>
</div>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="detach">Detach</Button>
<Button type="submit" :disabled="form.processing">Save attachment</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -6,20 +6,15 @@ import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
});
defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
}>();
const form = useForm({
key: "",
value: "",
overridable: true,
});
</script>
@@ -74,6 +69,22 @@ const form = useForm({
<InputError :message="form.errors.value" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.overridable" type="checkbox" class="size-4" />
<span>
Overridable
<span class="block text-muted-foreground">
Allows managed attachments to replace this variable if they need to.
</span>
</span>
</label>
<div class="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
Values are stored as environment variables and displayed masked in environment
overviews. Use locked variables for values that should not be replaced by generated
attachment output.
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing">Save</Button>
</div>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
variable: Record<string, any>;
}>();
const form = useForm({
key: props.variable.key,
value: props.variable.value ?? "",
overridable: Boolean(props.variable.overridable),
});
const destroyVariable = (): void => {
if (!window.confirm(`Delete ${props.variable.key}?`)) {
return;
}
router.delete(
route("environment-variables.destroy", {
organisation: props.application.organisation_id,
application: props.application.id,
environment: props.environment.id,
variable: props.variable.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${variable.key}`" />
<AppLayout
:breadcrumbs="[
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{
title: 'Variables',
href: route('environment-variables.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: variable.key },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('environment-variables.update', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
variable: variable.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Environment Variable</h2>
<div class="mt-2 flex flex-wrap gap-2">
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
{{ variable.source.replace("_", " ") }}
</Badge>
<Badge variant="outline">secret</Badge>
</div>
</div>
<div class="grid gap-2">
<Label for="key">Key</Label>
<Input id="key" v-model="form.key" required />
<InputError :message="form.errors.key" />
</div>
<div class="grid gap-2">
<Label for="value">Value</Label>
<Input id="value" v-model="form.value" type="password" />
<InputError :message="form.errors.value" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.overridable" type="checkbox" class="size-4" />
Overridable by managed attachments
</label>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroyVariable">
Delete variable
</Button>
<Button type="submit" :disabled="form.processing">Save variable</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import { CopyIcon, EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
import { ref } from "vue";
defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
variables: Record<string, any>[];
}>();
const importForm = useForm({
contents: "",
overridable: true,
});
const revealedVariableIds = ref<number[]>([]);
const copiedVariableId = ref<number | null>(null);
const isRevealed = (variable: Record<string, any>): boolean =>
revealedVariableIds.value.includes(variable.id);
const toggleReveal = (variable: Record<string, any>): void => {
revealedVariableIds.value = isRevealed(variable)
? revealedVariableIds.value.filter((id) => id !== variable.id)
: [...revealedVariableIds.value, variable.id];
};
const copyValue = async (variable: Record<string, any>): Promise<void> => {
await navigator.clipboard.writeText(variable.value ?? "");
copiedVariableId.value = variable.id;
window.setTimeout(() => {
if (copiedVariableId.value === variable.id) {
copiedVariableId.value = null;
}
}, 1500);
};
const importVariables = (): void => {
importForm.post(
route("environment-variables.import", {
organisation: route().params.organisation,
application: route().params.application,
environment: route().params.environment,
}),
{
preserveScroll: true,
onSuccess: () => importForm.reset("contents"),
},
);
};
const destroyVariable = (variable: Record<string, any>): void => {
if (!window.confirm(`Delete ${variable.key}?`)) {
return;
}
router.delete(
route("environment-variables.destroy", {
organisation: route().params.organisation,
application: route().params.application,
environment: route().params.environment,
variable: variable.id,
}),
);
};
</script>
<template>
<Head :title="`${environment.name} Variables`" />
<AppLayout
:breadcrumbs="[
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Variables' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-3xl font-bold tracking-tight">Environment Variables</h2>
<p class="mt-1 text-sm text-muted-foreground">
User values can be edited. Managed values show their source and lock state.
</p>
</div>
<Button
:as="Link"
:href="
route('environment-variables.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Add variable
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Variables</CardTitle>
<CardDescription>{{ variables.length }} configured values</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="variable in variables"
:key="variable.id"
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
route('environment-variables.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
variable: variable.id,
})
"
class="font-medium hover:underline"
>
{{ variable.key }}
</Link>
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'">
{{ variable.source.replace('_', ' ') }}
</Badge>
<Badge v-if="!variable.overridable" variant="outline">locked</Badge>
<Badge variant="outline">secret</Badge>
</div>
<p class="mt-1 text-muted-foreground">
{{ variable.service_slice?.name ?? "No slice source" }}
</p>
</div>
<code class="max-w-full truncate rounded bg-muted px-2 py-1 text-xs">
{{ isRevealed(variable) ? variable.value : "••••••••" }}
</code>
<Button
size="iconxs"
variant="ghost"
:aria-label="isRevealed(variable) ? `Hide ${variable.key}` : `Reveal ${variable.key}`"
@click="toggleReveal(variable)"
>
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" />
<EyeIcon v-else class="size-3" />
</Button>
<Button
size="iconxs"
variant="ghost"
:aria-label="`Copy ${variable.key}`"
@click="copyValue(variable)"
>
<CopyIcon class="size-3" />
<span class="sr-only">
{{ copiedVariableId === variable.id ? "Copied" : "Copy" }}
</span>
</Button>
<Button size="iconxs" variant="ghost" @click="destroyVariable(variable)">
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="variables.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No variables configured.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bulk Import</CardTitle>
<CardDescription>Paste KEY=value lines from an .env file.</CardDescription>
</CardHeader>
<CardContent>
<form class="grid gap-3" @submit.prevent="importVariables">
<textarea
v-model="importForm.contents"
class="min-h-40 rounded-md border border-input bg-transparent p-3 font-mono text-sm"
placeholder="APP_ENV=production&#10;APP_DEBUG=false"
required
/>
<InputError :message="importForm.errors.contents" />
<label class="flex items-center gap-2 text-sm">
<input
v-model="importForm.overridable"
type="checkbox"
class="size-4"
/>
Imported values are overridable
</label>
<InputError :message="importForm.errors.overridable" />
<div>
<Button type="submit" :disabled="importForm.processing">
Import variables
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
}>();
const form = useForm({
name: "",
branch: props.application.default_branch ?? "main",
php_version: "8.4",
});
</script>
<template>
<Head title="Create Environment" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{ title: 'Create Environment' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('environments.store', {
organisation: $page.props.organisation.id,
application: application.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Environment</h2>
<p class="mt-1 text-sm text-muted-foreground">
A Laravel web service is created with scheduler and migration defaults.
</p>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required placeholder="staging" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="branch">Branch</Label>
<Input id="branch" v-model="form.branch" required />
<InputError :message="form.errors.branch" />
</div>
<div class="grid gap-2">
<Label for="php_version">PHP version</Label>
<Input id="php_version" v-model="form.php_version" required />
<InputError :message="form.errors.php_version" />
</div>
</div>
<div class="flex justify-end">
<Button type="submit" :disabled="form.processing">Create environment</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
schedulerModes: string[];
buildStrategies: string[];
}>();
const buildConfig = props.environment.build_config ?? {};
const form = useForm({
name: props.environment.name,
branch: props.environment.branch,
status: props.environment.status,
scheduler_enabled: Boolean(props.environment.scheduler_enabled),
scheduler_target_service_id: props.environment.scheduler_target_service_id ?? "",
scheduler_mode: props.environment.scheduler_mode ?? "single",
build_strategy: buildConfig.build_strategy ?? "target_server",
php_version: buildConfig.php_version ?? "8.4",
document_root: buildConfig.document_root ?? "public",
health_path: buildConfig.health_path ?? "/up",
js_package_manager: buildConfig.js_package_manager ?? "bun",
js_build_command: buildConfig.js_build_command ?? "",
});
const destroyEnvironment = (): void => {
if (!window.confirm(`Delete ${props.environment.name}?`)) {
return;
}
router.delete(
route("environments.destroy", {
organisation: props.application.organisation_id,
application: props.application.id,
environment: props.environment.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${environment.name}`" />
<AppLayout
:breadcrumbs="[
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Edit' },
]"
>
<form
class="flex h-full max-w-4xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('environments.update', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Environment Settings</h2>
<p class="mt-1 text-sm text-muted-foreground">
Branch, scheduler, build strategy, and health check configuration.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-3">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="branch">Branch</Label>
<Input id="branch" v-model="form.branch" required />
<InputError :message="form.errors.branch" />
</div>
<div class="grid gap-2">
<Label for="status">Status</Label>
<Input id="status" v-model="form.status" required />
<InputError :message="form.errors.status" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Scheduler</CardTitle>
<CardDescription>Choose where scheduled commands should run.</CardDescription>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-3">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.scheduler_enabled" type="checkbox" class="size-4" />
Enabled
</label>
<div class="grid gap-2">
<Label for="scheduler_target_service_id">Target service</Label>
<select
id="scheduler_target_service_id"
v-model="form.scheduler_target_service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">No target</option>
<option
v-for="service in environment.services"
:key="service.id"
:value="service.id"
>
{{ service.name }}
</option>
</select>
<InputError :message="form.errors.scheduler_target_service_id" />
</div>
<div class="grid gap-2">
<Label for="scheduler_mode">Mode</Label>
<select
id="scheduler_mode"
v-model="form.scheduler_mode"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="mode in schedulerModes" :key="mode" :value="mode">
{{ mode.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.scheduler_mode" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Build & Health</CardTitle>
<CardDescription>Defaults used by deploy planning and runtime checks.</CardDescription>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="build_strategy">Build strategy</Label>
<select
id="build_strategy"
v-model="form.build_strategy"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="strategy in buildStrategies" :key="strategy" :value="strategy">
{{ strategy.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.build_strategy" />
</div>
<div class="grid gap-2">
<Label for="php_version">PHP version</Label>
<Input id="php_version" v-model="form.php_version" />
<InputError :message="form.errors.php_version" />
</div>
<div class="grid gap-2">
<Label for="document_root">Document root</Label>
<Input id="document_root" v-model="form.document_root" />
<InputError :message="form.errors.document_root" />
</div>
<div class="grid gap-2">
<Label for="health_path">Health path</Label>
<Input id="health_path" v-model="form.health_path" />
<InputError :message="form.errors.health_path" />
</div>
<div class="grid gap-2">
<Label for="js_package_manager">JS package manager</Label>
<Input id="js_package_manager" v-model="form.js_package_manager" />
<InputError :message="form.errors.js_package_manager" />
</div>
<div class="grid gap-2">
<Label for="js_build_command">JS build command</Label>
<Input id="js_build_command" v-model="form.js_build_command" />
<InputError :message="form.errors.js_build_command" />
</div>
</CardContent>
</Card>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroyEnvironment">
Delete environment
</Button>
<Button type="submit" :disabled="form.processing">Save settings</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { BoxesIcon, PlusIcon } from "lucide-vue-next";
defineProps<{
applications: Record<string, any>[];
}>();
</script>
<template>
<Head title="Environments" />
<AppLayout
:breadcrumbs="[
{
title: 'Environments',
href: route('environments.index', { organisation: $page.props.organisation.id }),
},
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-3xl font-bold tracking-tight">Environments</h2>
<p class="mt-1 text-sm text-muted-foreground">
Deployment units across all applications.
</p>
</div>
<Button
:as="Link"
:href="route('applications.create', { organisation: $page.props.organisation.id })"
>
<PlusIcon class="size-4" />
Application
</Button>
</div>
<div class="grid gap-4">
<Card v-for="application in applications" :key="application.id">
<CardHeader>
<CardTitle>{{ application.name }}</CardTitle>
<CardDescription>
{{ application.environments?.length ?? 0 }} environments
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<Link
v-for="environment in application.environments"
:key="environment.id"
:href="
route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
class="rounded-md border p-3 hover:bg-muted/50"
>
<div class="flex items-center gap-2">
<BoxesIcon class="size-4" />
<span class="font-medium">{{ environment.name }}</span>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">
{{ environment.status.replace('-', ' ') }}
</Badge>
</div>
<p class="mt-2 text-sm text-muted-foreground">
{{ environment.branch }} · {{ environment.services_count }} services ·
{{ environment.build_artifacts_count }} builds
</p>
</Link>
</CardContent>
</Card>
<Card v-if="applications.every((application) => !application.environments?.length)">
<CardHeader>
<CardTitle>No environments yet</CardTitle>
<CardDescription>
Create an application to provision its first environment.
</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,29 +1,67 @@
<script setup>
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import InputError from "@/components/InputError.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import {
DatabaseIcon,
GitBranchIcon,
ListChecksIcon,
PencilIcon,
PlusIcon,
RocketIcon,
ServerIcon,
SettingsIcon,
} from "lucide-vue-next";
import { computed } from "vue";
const props = defineProps({
application: {
type: Object,
required: true,
},
environment: {
type: Object,
required: true,
},
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
deploymentRequirements: {
registryRequired: boolean;
registryCount: number;
serverCount: number;
};
gatewayRoutePreviews: {
attachment_id: number;
caddyfile: string;
}[];
}>();
const gatewayAttachments = computed(() =>
props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
);
const gatewayCutovers = computed(() =>
props.environment.operations.filter((operation) => operation.kind === "gateway_cutover"),
);
const caddyfilePreviewFor = (attachmentId: number): string =>
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)?.caddyfile ??
"# No route preview available";
const deployForm = useForm({
target_commit: "",
});
const deployEnvironment = (): void => {
deployForm.post(
route("environment-deployments.store", {
organisation: route().params.organisation,
application: props.application.id,
environment: props.environment.id,
}),
{
preserveScroll: true,
},
);
};
</script>
<template>
@@ -58,21 +96,34 @@ const props = defineProps({
<p class="mt-1 text-sm text-muted-foreground">
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
</p>
<p class="mt-1 text-sm text-muted-foreground">
Scheduler:
{{
environment.scheduler_enabled
? `${environment.scheduler_mode} on ${
environment.services?.find(
(service) =>
service.id === environment.scheduler_target_service_id,
)?.name ?? "selected service"
}`
: "disabled"
}}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
@click="
router.post(
route('environment-deployments.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
:as="Link"
variant="secondary"
:href="
route('environments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<RocketIcon class="size-4" />
Deploy
<PencilIcon class="size-4" />
Settings
</Button>
<Button
variant="secondary"
@@ -106,6 +157,66 @@ const props = defineProps({
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Deploy Target</CardTitle>
<CardDescription>
Deploy the current {{ environment.branch }} branch head, or pin this
deployment to a specific commit SHA.
</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-3 md:flex-row md:items-end" @submit.prevent="deployEnvironment">
<div class="grid flex-1 gap-2">
<Label for="target_commit">Commit SHA</Label>
<Input
id="target_commit"
v-model="deployForm.target_commit"
placeholder="Leave blank to resolve the branch head"
maxlength="40"
/>
<InputError :message="deployForm.errors.target_commit" />
</div>
<Button
type="submit"
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
>
<RocketIcon class="size-4" />
Deploy
</Button>
</form>
</CardContent>
</Card>
<Card v-if="deploymentRequirements.registryRequired" class="border-amber-200 bg-amber-50">
<CardHeader>
<CardTitle>Registry Required</CardTitle>
<CardDescription>
This environment spans {{ deploymentRequirements.serverCount }} servers.
Configure a registry before deploying so every server can pull the same
artifact.
</CardDescription>
</CardHeader>
<CardContent>
<Button
:as="Link"
variant="secondary"
:href="
route('registries.create', {
organisation: $page.props.organisation.id,
})
"
>
Add registry
</Button>
</CardContent>
</Card>
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
<div class="space-y-4">
<Card>
@@ -136,14 +247,14 @@ const props = defineProps({
</p>
</div>
<Button
v-if="service.server_id"
:as="Link"
size="sm"
variant="secondary"
:href="
route('services.show', {
route('environment-services.show', {
organisation: $page.props.organisation.id,
server: service.server_id,
application: environment.application_id,
environment: environment.id,
service: service.id,
})
"
@@ -160,21 +271,59 @@ const props = defineProps({
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="environment.operations" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-3">
<div>
<CardTitle>Builds</CardTitle>
<CardDescription>
Recent artifacts planned or built for this environment.
</CardDescription>
</div>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('build-artifacts.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
View all
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="operation in environment.operations"
:key="operation.id"
class="flex items-center justify-between rounded-md border p-3"
v-for="artifact in environment.build_artifacts"
:key="artifact.id"
class="rounded-md border p-3 text-sm"
>
<span class="font-medium">{{
operation.kind.replace("_", " ")
}}</span>
<Badge
:variant="
operation.status === 'completed' ? 'success' : 'secondary'
"
>{{ operation.status.replace("_", " ") }}</Badge
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{{ artifact.status }}</Badge>
<span class="font-medium">{{ artifact.commit_sha }}</span>
<span class="text-muted-foreground">{{ artifact.image_tag }}</span>
</div>
<p class="mt-1 text-muted-foreground">
{{ artifact.registry_ref ?? "No registry ref" }}
<span v-if="artifact.image_digest">
· {{ artifact.image_digest }}
</span>
</p>
</div>
<div
v-if="environment.build_artifacts.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No builds recorded for this environment.
</div>
</CardContent>
</Card>
@@ -193,16 +342,131 @@ const props = defineProps({
>
<div class="flex items-center gap-2 font-medium">
<DatabaseIcon class="size-4" />
{{ attachment.role.replace("_", " ") }}
<Link
:href="
route('environment-attachments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
attachment: attachment.id,
})
"
class="hover:underline"
>
{{ attachment.role.replace("_", " ") }}
</Link>
</div>
<p class="mt-1 text-muted-foreground">
{{ attachment.service?.name }} ·
{{ attachment.service_slice?.name ?? "service level" }}
</p>
<div
v-if="attachment.role === 'gateway'"
class="mt-2 grid gap-1 text-xs text-muted-foreground"
>
<div>
Domain:
{{ attachment.service_slice?.config?.domain ?? "not set" }}
</div>
<div>
Path:
{{ attachment.service_slice?.config?.path_prefix ?? "/" }}
· TLS
{{
attachment.service_slice?.config?.tls_enabled === false
? "disabled"
: "enabled"
}}
</div>
<div>
Certificate:
{{
attachment.service_slice?.config?.certificate_status ??
"pending"
}}
</div>
</div>
</div>
</CardContent>
</Card>
<Card v-if="gatewayAttachments.length > 0">
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Gateway Cutover</CardTitle>
<CardDescription>
Route validation, reload, upstream health, and drain sequence.
</CardDescription>
</div>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-3">
<div
v-for="attachment in gatewayAttachments"
:key="attachment.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">
{{ attachment.service_slice?.config?.domain ?? "Unassigned domain" }}
</div>
<div class="text-muted-foreground">
Caddyfile: /home/keystone/gateway/Caddyfile
</div>
<pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
caddyfilePreviewFor(attachment.id)
}}</pre>
<div class="mt-2 flex flex-wrap gap-2">
<Badge variant="outline">Render route</Badge>
<Badge variant="outline">Health check</Badge>
<Badge variant="outline">Reload gateway</Badge>
<Badge variant="outline">Drain old upstream</Badge>
</div>
</div>
<OperationTimeline :operations="gatewayCutovers" />
</CardContent>
</Card>
<Card v-else>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Gateway Routes</CardTitle>
<CardDescription>
No gateway routes are configured for this environment.
</CardDescription>
</div>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>Variables</CardTitle>
@@ -221,7 +485,7 @@ const props = defineProps({
size="sm"
variant="secondary"
:href="
route('environment-variables.create', {
route('environment-variables.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
@@ -229,10 +493,41 @@ const props = defineProps({
"
>
<PlusIcon class="size-4" />
Add
Manage
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Service policy</CardTitle>
<CardDescription>
Migration and scheduler-related defaults exposed by current
services.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<div
v-for="service in environment.services"
:key="service.id"
class="rounded-md border p-3"
>
<div class="font-medium">{{ service.name }}</div>
<div class="text-muted-foreground">
Deploy policy: {{ service.deploy_policy ?? "default" }} ·
Roles: {{ service.process_roles?.join(", ") || "none" }}
</div>
<div class="text-muted-foreground">
Migration:
{{
service.config?.migration_mode ??
service.config?.migration_timing ??
"not configured"
}}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
services: Record<string, any>[];
}>();
const form = useForm({
service_id: props.services[0]?.id ?? null,
name: "",
domain: "",
path_prefix: "/",
tls_enabled: true,
});
</script>
<template>
<Head title="Add Gateway Route" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{
title: 'Gateway routes',
href: route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Add' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('gateway.routes.store', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Add Gateway Route</h2>
<p class="mt-1 text-sm text-muted-foreground">
Create a Caddy route slice for a domain and path prefix.
</p>
</div>
<div class="grid gap-2">
<Label for="service_id">Gateway service</Label>
<select
id="service_id"
v-model="form.service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option v-for="service in services" :key="service.id" :value="service.id">
{{ service.name }}
</option>
</select>
<InputError :message="form.errors.service_id" />
</div>
<div class="grid gap-2">
<Label for="name">Route slice name</Label>
<Input id="name" v-model="form.name" placeholder="billing_web" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" required />
<InputError :message="form.errors.path_prefix" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
TLS enabled
</label>
<div class="flex justify-end">
<Button type="submit" :disabled="form.processing || services.length === 0">
Create route
</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
routeAttachment: Record<string, any>;
}>();
const form = useForm({
domain: props.routeAttachment.service_slice?.config?.domain ?? "",
path_prefix: props.routeAttachment.service_slice?.config?.path_prefix ?? "/",
tls_enabled: props.routeAttachment.service_slice?.config?.tls_enabled ?? true,
certificate_status: props.routeAttachment.service_slice?.config?.certificate_status ?? "",
});
const destroyRoute = (): void => {
if (!window.confirm(`Remove gateway route ${form.domain}?`)) {
return;
}
router.delete(
route("gateway.routes.destroy", {
organisation: route().params.organisation,
application: props.application.id,
environment: props.environment.id,
route: props.routeAttachment.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${form.domain}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{
title: 'Gateway routes',
href: route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Edit' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('gateway.routes.update', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
route: routeAttachment.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Gateway Route</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ routeAttachment.service?.name }} ·
{{ routeAttachment.service_slice?.name ?? "route slice" }}
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required />
<InputError :message="form.errors.domain" />
</div>
<div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" placeholder="/" required />
<InputError :message="form.errors.path_prefix" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.tls_enabled" type="checkbox" class="size-4" />
TLS enabled
</label>
<div class="grid gap-2">
<Label for="certificate_status">Certificate status</Label>
<Input id="certificate_status" v-model="form.certificate_status" placeholder="pending" />
<InputError :message="form.errors.certificate_status" />
</div>
<div class="flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" @click="destroyRoute">Remove</Button>
<Button type="submit" :disabled="form.processing">Save route</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
const props = defineProps<{
application: Record<string, any>;
environment: Record<string, any>;
routes: Record<string, any>[];
}>();
const destroyRoute = (routeAttachment: Record<string, any>): void => {
const domain = routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name;
if (!window.confirm(`Remove gateway route ${domain}?`)) {
return;
}
router.delete(
route("gateway.routes.destroy", {
organisation: route().params.organisation,
application: props.application.id,
environment: props.environment.id,
route: routeAttachment.id,
}),
{ preserveScroll: true },
);
};
</script>
<template>
<Head :title="`${environment.name} gateway routes`" />
<AppLayout
:breadcrumbs="[
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
{ title: 'Gateway routes' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-3xl font-bold tracking-tight">Gateway Routes</h2>
<p class="mt-1 text-sm text-muted-foreground">
Domains, path prefixes, TLS state, and Caddy route slices.
</p>
</div>
<Button
:as="Link"
:href="
route('gateway.routes.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Add route
</Button>
</div>
<div class="grid gap-4">
<Card v-for="routeAttachment in routes" :key="routeAttachment.id">
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>
{{ routeAttachment.service_slice?.config?.domain ?? "Unassigned domain" }}
</CardTitle>
<CardDescription>
{{ routeAttachment.service?.name }} ·
{{ routeAttachment.service_slice?.name ?? "route slice" }}
</CardDescription>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ routeAttachment.service_slice?.config?.path_prefix ?? "/" }}
</Badge>
<Badge
:variant="
routeAttachment.service_slice?.config?.tls_enabled === false
? 'secondary'
: 'success'
"
>
TLS
{{
routeAttachment.service_slice?.config?.tls_enabled === false
? "disabled"
: "enabled"
}}
</Badge>
<Badge variant="outline">
{{
routeAttachment.service_slice?.config?.certificate_status ??
"pending"
}}
</Badge>
</div>
</div>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
route: routeAttachment.id,
})
"
>
<PencilIcon class="size-4" />
Edit
</Button>
<Button size="xs" variant="ghost" @click="destroyRoute(routeAttachment)">
<Trash2Icon class="size-4" />
Remove
</Button>
</CardContent>
</Card>
<Card v-if="routes.length === 0" class="border-dashed">
<CardHeader>
<CardTitle>No gateway routes</CardTitle>
<CardDescription>
Add a route to connect a domain and path prefix to a Caddy gateway.
</CardDescription>
</CardHeader>
<CardContent>
<Button
:as="Link"
variant="secondary"
:href="
route('gateway.routes.create', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
<PlusIcon class="size-4" />
Add route
</Button>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -6,11 +6,11 @@ import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { CheckIcon, CircleIcon } from "lucide-vue-next";
defineProps({
organisation: { type: Object, required: true },
steps: { type: Array, required: true },
nextStep: { type: Object, required: true },
});
defineProps<{
organisation: Record<string, any>;
steps: Record<string, any>[];
nextStep: Record<string, any>;
}>();
</script>
<template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, usePage, usePoll } from "@inertiajs/vue3";
import { computed } from "vue";
const props = defineProps<{
operations: Record<string, any>;
filters: Record<string, string>;
operationKinds: Record<string, string>;
operationStatuses: Record<string, string>;
}>();
const operationRows = computed(() => props.operations.data ?? []);
const page = usePage();
usePoll(5000, {}, { keepAlive: true });
const setFilter = (key: string, value: string | null): void => {
router.get(
route("operations.index", { organisation: page.props.organisation.id }),
{ ...props.filters, [key]: value || undefined },
{ preserveState: true, replace: true },
);
};
</script>
<template>
<Head title="Operations" />
<AppLayout
:breadcrumbs="[
{
title: 'Operations',
href: route('operations.index', { organisation: $page.props.organisation.id }),
},
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div>
<h2 class="text-3xl font-bold tracking-tight">Operations</h2>
<p class="mt-1 text-sm text-muted-foreground">
Organisation-wide execution history and logs.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button
size="sm"
:variant="!filters.kind ? 'default' : 'secondary'"
@click="setFilter('kind', null)"
>
All kinds
</Button>
<Button
v-for="kind in operationKinds"
:key="kind"
size="sm"
:variant="filters.kind === kind ? 'default' : 'secondary'"
@click="setFilter('kind', kind)"
>
{{ kind.replace('_', ' ') }}
</Button>
<Button
v-for="status in operationStatuses"
:key="status"
size="sm"
:variant="filters.status === status ? 'default' : 'outline'"
@click="setFilter('status', filters.status === status ? null : status)"
>
{{ status.replace('-', ' ') }}
</Button>
</CardContent>
</Card>
<OperationTimeline :operations="operationRows" show-target />
<div v-if="operations.links?.length > 3" class="flex flex-wrap gap-2">
<Button
v-for="link in operations.links"
:key="link.label"
:as="link.url ? Link : 'button'"
:href="link.url ?? undefined"
size="sm"
:variant="link.active ? 'default' : 'secondary'"
:disabled="!link.url"
v-html="link.label"
/>
</div>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, usePoll } from "@inertiajs/vue3";
defineProps<{
operation: Record<string, any>;
}>();
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
usePoll(5000, {}, { keepAlive: true });
const retryOperation = (operation: Record<string, any>): void => {
router.post(
route("operations.retry", {
organisation: route().params.organisation,
operation: operation.id,
}),
);
};
const cancelOperation = (operation: Record<string, any>): void => {
router.post(
route("operations.cancel", {
organisation: route().params.organisation,
operation: operation.id,
}),
);
};
</script>
<template>
<Head :title="`Operation ${operation.hash}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Operations',
href: route('operations.index', { organisation: $page.props.organisation.id }),
},
{ title: operation.hash },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ label(operation.kind) }}</h2>
<Badge variant="outline">{{ operation.hash }}</Badge>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">
{{ label(operation.status) }}
</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
Started {{ operation.started_at ?? "not yet" }} · Finished
{{ operation.finished_at ?? "not yet" }}
</p>
</div>
<Button
v-if="operation.status === 'failed'"
variant="secondary"
@click="retryOperation(operation)"
>
Re-run
</Button>
<Button
v-if="['pending', 'in-progress'].includes(operation.status)"
variant="secondary"
@click="cancelOperation(operation)"
>
Cancel
</Button>
<Button
:as="Link"
variant="secondary"
:href="
route('operations.logs', {
organisation: $page.props.organisation.id,
operation: operation.id,
})
"
>
Download logs
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Target</CardTitle>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<div>{{ operation.target?.name ?? `#${operation.target_id}` }}</div>
<div class="text-muted-foreground">{{ operation.target_type }}</div>
<Link
v-if="operation.parent"
:href="
route('operations.show', {
organisation: $page.props.organisation.id,
operation: operation.parent.id,
})
"
class="hover:underline"
>
Parent: {{ operation.parent.hash }}
</Link>
</CardContent>
</Card>
<OperationTimeline :operations="[operation]" />
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
import { Trash2Icon } from "lucide-vue-next";
const props = defineProps<{
organisation: Record<string, any>;
roles: string[];
}>();
const inviteForm = useForm({
email: "",
role: "member",
});
const updateRole = (member: Record<string, any>, role: string): void => {
router.put(
route("organisation-members.update", {
organisation: props.organisation.id,
member: member.id,
}),
{ role },
{ preserveScroll: true },
);
};
const updateInvitationRole = (invitation: Record<string, any>, role: string): void => {
router.put(
route("organisation-invitations.update", {
organisation: props.organisation.id,
invitation: invitation.id,
}),
{ role },
{ preserveScroll: true },
);
};
const removeMember = (member: Record<string, any>): void => {
if (!window.confirm(`Remove ${member.name} from ${props.organisation.name}?`)) {
return;
}
router.delete(
route("organisation-members.destroy", {
organisation: props.organisation.id,
member: member.id,
}),
{ preserveScroll: true },
);
};
const cancelInvitation = (invitation: Record<string, any>): void => {
if (!window.confirm(`Cancel invitation for ${invitation.email}?`)) {
return;
}
router.delete(
route("organisation-invitations.destroy", {
organisation: props.organisation.id,
invitation: invitation.id,
}),
{ preserveScroll: true },
);
};
</script>
<template>
<Head :title="`${organisation.name} Members`" />
<AppLayout
:breadcrumbs="[
{
title: organisation.name,
href: route('organisations.show', { organisation: organisation.id }),
},
{ title: 'Members' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div>
<h2 class="text-3xl font-bold tracking-tight">Members</h2>
<p class="mt-1 text-sm text-muted-foreground">
Invite teammates, change roles, and remove access.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Invite Member</CardTitle>
<CardDescription>
Existing users are added immediately. New emails remain pending until accepted.
</CardDescription>
</CardHeader>
<CardContent>
<form
class="grid gap-4 md:grid-cols-[1fr_180px_auto]"
@submit.prevent="
inviteForm.post(
route('organisation-members.store', {
organisation: organisation.id,
}),
{ preserveScroll: true, onSuccess: () => inviteForm.reset('email') },
)
"
>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" v-model="inviteForm.email" type="email" required />
<InputError :message="inviteForm.errors.email" />
</div>
<div class="grid gap-2">
<Label for="role">Role</Label>
<select
id="role"
v-model="inviteForm.role"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
<InputError :message="inviteForm.errors.role" />
</div>
<div class="flex items-end">
<Button type="submit" :disabled="inviteForm.processing">Invite</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>
{{ organisation.invitations.length }} pending invitations
</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="invitation in organisation.invitations"
:key="invitation.id"
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
>
<div class="min-w-0 flex-1">
<div class="font-medium">{{ invitation.email }}</div>
<div class="text-muted-foreground">
Invited by
{{ invitation.invited_by?.name ?? "Keystone" }}
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span>
</div>
</div>
<select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
:value="invitation.role ?? 'member'"
@change="
updateInvitationRole(
invitation,
($event.target as HTMLSelectElement).value,
)
"
>
<option v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
<Button
size="iconxs"
variant="ghost"
:aria-label="`Cancel invitation for ${invitation.email}`"
@click="cancelInvitation(invitation)"
>
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="organisation.invitations.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No pending invitations.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Roster</CardTitle>
<CardDescription>{{ organisation.members.length }} members</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="member in organisation.members"
:key="member.id"
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
>
<div class="min-w-0 flex-1">
<div class="font-medium">{{ member.name }}</div>
<div class="text-muted-foreground">{{ member.email }}</div>
</div>
<select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
:value="member.membership?.role ?? 'member'"
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
>
<option v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
<Button
size="iconxs"
variant="ghost"
:disabled="member.id === organisation.owner_id"
@click="removeMember(member)"
>
<Trash2Icon class="size-3" />
</Button>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, WhenVisible } from "@inertiajs/vue3";
import { Head, Link, router, WhenVisible } from "@inertiajs/vue3";
import {
AppWindowIcon,
GitBranchIcon,
PencilIcon,
ServerIcon,
ShieldCheckIcon,
Trash2Icon,
UserIcon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
@@ -30,6 +32,10 @@ defineProps({
type: Array,
required: false,
},
health: {
type: Object,
required: true,
},
});
const tabValue = ref(new URL(window.location.href).hash?.replace("#", "") || "dashboard");
@@ -44,6 +50,14 @@ watch(
}
},
);
const destroyResource = (url: string, label: string): void => {
if (!window.confirm(`Delete ${label}?`)) {
return;
}
router.delete(url, { preserveScroll: true });
};
</script>
<template>
@@ -51,7 +65,22 @@ watch(
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<h2 class="text-3xl font-bold tracking-tight">{{ organisation.name }}</h2>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-3xl font-bold tracking-tight">{{ organisation.name }}</h2>
<Button
v-if="
organisation.providers_count === 0 ||
organisation.source_providers_count === 0 ||
organisation.registries_count === 0 ||
organisation.servers_count === 0 ||
organisation.applications_count === 0
"
:as="Link"
:href="route('onboarding.show', { organisation: organisation.id })"
>
Continue onboarding
</Button>
</div>
<Tabs v-model="tabValue" :unmount-on-hide="false">
<TabsList>
<TabsTrigger value="dashboard"> Dashboard </TabsTrigger>
@@ -121,6 +150,63 @@ watch(
</CardContent>
</Card>
</div>
<Card class="mt-4">
<CardHeader>
<CardTitle>Health</CardTitle>
<CardDescription>Aggregate signals across this organisation.</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-3">
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.unhealthy_services }}</div>
<div class="text-sm text-muted-foreground">Unhealthy services</div>
</div>
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.failed_operations }}</div>
<div class="text-sm text-muted-foreground">Failed operations</div>
</div>
<div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.locked_variables }}</div>
<div class="text-sm text-muted-foreground">Environments with locked variables</div>
</div>
</CardContent>
</Card>
<Card class="mt-4">
<CardHeader>
<div class="flex items-center justify-between gap-3">
<div>
<CardTitle>Members</CardTitle>
<CardDescription>Current organisation roster.</CardDescription>
</div>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('organisation-members.index', {
organisation: organisation.id,
})
"
>
Manage
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="member in organisation.members"
:key="member.id"
class="flex items-center justify-between rounded-md border p-3 text-sm"
>
<div>
<div class="font-medium">{{ member.name }}</div>
<div class="text-muted-foreground">{{ member.email }}</div>
</div>
<span class="text-xs uppercase text-muted-foreground">
{{ member.membership?.role ?? "member" }}
</span>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<WhenVisible data="registries">
@@ -147,10 +233,48 @@ watch(
class="flex items-center gap-2 px-2 py-1"
>
<ShieldCheckIcon class="size-4 text-muted-foreground" />
{{ registry.name }}
<Link
:href="
route('registries.show', {
organisation: organisation.id,
registry: registry.id,
})
"
class="hover:underline"
>
{{ registry.name }}
</Link>
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
registry.type
}}</span>
<Button
:as="Link"
size="iconxs"
variant="ghost"
:href="
route('registries.edit', {
organisation: organisation.id,
registry: registry.id,
})
"
>
<PencilIcon class="size-3" />
</Button>
<Button
size="iconxs"
variant="ghost"
@click="
destroyResource(
route('registries.destroy', {
organisation: organisation.id,
registry: registry.id,
}),
registry.name,
)
"
>
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="!registries?.length"
@@ -185,10 +309,35 @@ watch(
class="flex items-center gap-2 px-2 py-1"
>
<GitBranchIcon class="size-4 text-muted-foreground" />
{{ sourceProvider.name }}
<Link
:href="
route('source-providers.edit', {
organisation: organisation.id,
source_provider: sourceProvider.id,
})
"
class="hover:underline"
>
{{ sourceProvider.name }}
</Link>
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
sourceProvider.type
}}</span>
<Button
size="iconxs"
variant="ghost"
@click="
destroyResource(
route('source-providers.destroy', {
organisation: organisation.id,
source_provider: sourceProvider.id,
}),
sourceProvider.name,
)
"
>
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="!sourceProviders?.length"
@@ -201,21 +350,53 @@ watch(
</WhenVisible>
<WhenVisible data="providers">
<template #fallback> Loading... </template>
<h3 class="mt-4 text-2xl font-bold tracking-tight">Server Providers</h3>
<p class="mb-4 text-sm text-muted-foreground">
Manage your server providers.
</p>
<div class="mt-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-2xl font-bold tracking-tight">Server Providers</h3>
<p class="mb-4 text-sm text-muted-foreground">
Manage your server providers.
</p>
</div>
<Button
:as="Link"
:href="route('providers.create', { organisation: organisation.id })"
>
Add
</Button>
</div>
<div
class="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border"
>
<div
v-for="provider in providers"
:key="provider.id"
class="flex items-center gap-2 px-2 py-1"
>
{{ provider.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
provider.type
}}</span>
<Button
size="iconxs"
variant="ghost"
@click="
destroyResource(
route('providers.destroy', {
organisation: organisation.id,
provider: provider.id,
}),
provider.name,
)
"
>
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="!providers?.length"
class="px-2 py-1 text-sm text-muted-foreground"
>
No server providers configured
</div>
</div>
</WhenVisible>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
defineProps<{
providerTypes: string[];
}>();
const form = useForm({
name: "",
type: "hetzner",
token: "",
});
</script>
<template>
<Head title="Add Server Provider" />
<AppLayout
:breadcrumbs="[
{
title: $page.props.organisation.name,
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: 'Add Server Provider' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(route('providers.store', { organisation: $page.props.organisation.id }))
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Add Server Provider</h2>
<p class="mt-1 text-sm text-muted-foreground">
Provider credentials are encrypted and used to create servers and private
networks.
</p>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required placeholder="Hetzner production" />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="type in providerTypes" :key="type" :value="type">
{{ type.replace("-", " ") }}
</option>
</select>
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2">
<Label for="token">API token</Label>
<Input id="token" v-model="form.token" type="password" required />
<InputError :message="form.errors.token" />
</div>
<div class="flex justify-end">
<Button type="submit" :disabled="form.processing">Save provider</Button>
</div>
</form>
</AppLayout>
</template>

View File

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

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
registry: Record<string, any>;
registryTypes: string[];
}>();
const form = useForm({
name: props.registry.name,
type: props.registry.type,
url: props.registry.url,
username: "",
password: "",
});
const destroyRegistry = (): void => {
if (!window.confirm(`Delete ${props.registry.name}?`)) {
return;
}
router.delete(
route("registries.destroy", {
organisation: props.registry.organisation_id,
registry: props.registry.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${registry.name}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: 'Edit Registry' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('registries.update', {
organisation: $page.props.organisation.id,
registry: registry.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Registry</h2>
<p class="mt-1 text-sm text-muted-foreground">
Leave password blank to keep the current credential.
</p>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType">
{{ registryType.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2">
<Label for="url">Registry URL</Label>
<Input id="url" v-model="form.url" required />
<InputError :message="form.errors.url" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="username">Username</Label>
<Input id="username" v-model="form.username" autocomplete="username" />
<InputError :message="form.errors.username" />
</div>
<div class="grid gap-2">
<Label for="password">New password/token</Label>
<Input
id="password"
v-model="form.password"
type="password"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" />
</div>
</div>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroyRegistry">
Delete registry
</Button>
<Button type="submit" :disabled="form.processing">Save registry</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { EyeIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
defineProps<{
registries: Record<string, any>[];
}>();
const destroyRegistry = (registry: Record<string, any>): void => {
if (!window.confirm(`Delete ${registry.name}?`)) {
return;
}
router.delete(
route("registries.destroy", {
organisation: route().params.organisation,
registry: registry.id,
}),
);
};
</script>
<template>
<Head title="Registries" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: 'Registries' },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-3xl font-bold tracking-tight">Registries</h2>
<p class="mt-1 text-sm text-muted-foreground">
Container registries used for multi-server environment deployments.
</p>
</div>
<Button
:as="Link"
:href="route('registries.create', { organisation: $page.props.organisation.id })"
>
<PlusIcon class="size-4" />
Add registry
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Configured Registries</CardTitle>
<CardDescription>{{ registries.length }} registries</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="registry in registries"
:key="registry.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ registry.name }}</span>
<Badge variant="outline">{{ registry.type?.replace("_", " ") }}</Badge>
</div>
<div class="mt-1 text-muted-foreground">
{{ registry.url ?? "No registry URL configured" }}
</div>
</div>
<div class="flex gap-1">
<Button
:as="Link"
size="iconxs"
variant="ghost"
:href="
route('registries.show', {
organisation: $page.props.organisation.id,
registry: registry.id,
})
"
>
<EyeIcon class="size-3" />
</Button>
<Button
:as="Link"
size="iconxs"
variant="ghost"
:href="
route('registries.edit', {
organisation: $page.props.organisation.id,
registry: registry.id,
})
"
>
<PencilIcon class="size-3" />
</Button>
<Button size="iconxs" variant="ghost" @click="destroyRegistry(registry)">
<Trash2Icon class="size-3" />
</Button>
</div>
</div>
<div
v-if="registries.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No registries configured.
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PencilIcon } from "lucide-vue-next";
defineProps<{
registry: Record<string, any>;
artifactCount: number;
environmentCount: number;
artifacts: Record<string, any>;
}>();
</script>
<template>
<Head :title="registry.name" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: registry.name },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ registry.name }}</h2>
<Badge variant="outline">{{ registry.type.replace("_", " ") }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ registry.url ?? "No registry URL configured" }}
</p>
</div>
<Button
:as="Link"
variant="secondary"
:href="
route('registries.edit', {
organisation: $page.props.organisation.id,
registry: registry.id,
})
"
>
<PencilIcon class="size-4" />
Edit
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Artifacts</CardTitle>
</CardHeader>
<CardContent class="text-3xl font-semibold">{{ artifactCount }}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Environments</CardTitle>
</CardHeader>
<CardContent class="text-3xl font-semibold">{{ environmentCount }}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credential</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Stored encrypted. Rotate it from registry settings.
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Published Artifacts</CardTitle>
<CardDescription>
Artifacts whose registry reference starts with this registry URL.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<Link
v-for="artifact in artifacts.data"
:key="artifact.id"
:href="
route('build-artifacts.show', {
organisation: $page.props.organisation.id,
application: artifact.environment.application.id,
environment: artifact.environment.id,
artifact: artifact.id,
})
"
class="rounded-md border p-3 text-sm hover:bg-muted/50"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{{ artifact.status }}</Badge>
<span class="font-medium">{{ artifact.environment.name }}</span>
<span class="text-muted-foreground">{{ artifact.commit_sha }}</span>
</div>
<p class="mt-1 text-muted-foreground">
{{ artifact.registry_ref ?? "No registry ref" }}
</p>
</Link>
<div
v-if="artifacts.data.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No artifacts have been published to this registry.
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

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

View File

@@ -1,16 +1,15 @@
<script setup>
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PlusIcon } from "lucide-vue-next";
const props = defineProps({
servers: {
type: [Object, null],
required: true,
},
});
defineProps<{
servers: Record<string, any>;
networks: Record<string, any>[];
}>();
</script>
<template>
@@ -27,7 +26,12 @@ const props = defineProps({
]"
>
<div class="flex items-center justify-between gap-3 p-4">
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
<div>
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
<p class="mt-1 text-sm text-muted-foreground">
Compute nodes, private networking, firewall status, and hosted services.
</p>
</div>
<div>
<Button
:as="Link"
@@ -36,14 +40,16 @@ const props = defineProps({
organisation: $page.props.organisation.id,
})
"
>Create</Button
>
<PlusIcon class="size-4" />
Create
</Button>
</div>
</div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="server in servers.data"
:key="`server{$servers.id}`"
:key="server.id"
class="relative w-full"
>
<CardHeader>
@@ -65,6 +71,91 @@ const props = defineProps({
class="absolute inset-0"
></Link>
</Card>
<Card v-if="servers.data.length === 0" class="md:col-span-2 lg:col-span-3">
<CardHeader>
<CardTitle>No servers yet</CardTitle>
<CardDescription>
Create the first server or continue onboarding to configure providers,
source access, and registry details.
</CardDescription>
<div class="flex flex-wrap gap-2">
<Button
:as="Link"
:href="
route('servers.create', {
organisation: $page.props.organisation.id,
})
"
>
<PlusIcon class="size-4" />
Create server
</Button>
<Button
:as="Link"
variant="secondary"
:href="
route('onboarding.show', {
organisation: $page.props.organisation.id,
})
"
>
Onboarding
</Button>
</div>
</CardHeader>
</Card>
</div>
<section class="grid gap-4 p-4">
<div>
<h3 class="text-xl font-semibold tracking-tight">Private Networks</h3>
<p class="mt-1 text-sm text-muted-foreground">
Provider network zones and the servers attached to each private range.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<Card v-for="network in networks" :key="network.id">
<CardHeader>
<CardTitle>{{ network.name }}</CardTitle>
<CardDescription>
{{ network.ip_range }} · {{ network.network_zone }} ·
{{ network.servers.length }} servers
</CardDescription>
</CardHeader>
<div class="grid gap-2 px-6 pb-6">
<Link
v-for="server in network.servers"
:key="server.id"
:href="
route('servers.show', {
organisation: $page.props.organisation.id,
server: server.id,
})
"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm hover:bg-muted/50"
>
<span class="font-medium">{{ server.name }}</span>
<span class="text-muted-foreground">
{{ server.private_ip ?? "no private IP" }} ·
{{ server.status.replace("-", " ") }}
</span>
</Link>
<div
v-if="network.servers.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No servers attached.
</div>
</div>
</Card>
<Card v-if="networks.length === 0" class="border-dashed">
<CardHeader>
<CardTitle>No private networks</CardTitle>
<CardDescription>
Networks are created when the first server is provisioned for a provider zone.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
</AppLayout>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { Head, Link, router, useForm } from "@inertiajs/vue3";
import { useCycleList, useInterval } from "@vueuse/core";
import {
DatabaseIcon,
@@ -12,18 +15,17 @@ import {
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
import { computed, watch } from "vue";
defineProps({
const props = defineProps({
server: {
type: Object,
required: true,
},
});
const selectedStep = ref(null);
const { state: provisionMessage, next } = useCycleList([
"Provisioning your server...",
"Updating dependencies...",
@@ -32,11 +34,73 @@ const { state: provisionMessage, next } = useCycleList([
"Configuring ssh...",
"Installing docker...",
]);
const { counter, reset, pause, resume } = useInterval(5000, { controls: true });
const { counter } = useInterval(5000, { controls: true });
watch(counter, () => {
next();
});
const activeProvisionOperation = computed(() =>
props.server.operations?.find((operation) => operation.kind === "server_provision"),
);
const firewallForm = useForm({
type: "allow",
ports: "",
from: "",
});
const addFirewallRule = (): void => {
firewallForm.post(
route("servers.firewall-rules.store", {
organisation: props.server.organisation_id,
server: props.server.id,
}),
{
preserveScroll: true,
onSuccess: () => firewallForm.reset("ports", "from"),
},
);
};
const destroyFirewallRule = (rule: Record<string, any>): void => {
if (!window.confirm(`Remove ${rule.type} ${rule.ports}?`)) {
return;
}
router.delete(
route("servers.firewall-rules.destroy", {
organisation: props.server.organisation_id,
server: props.server.id,
firewallRule: rule.id,
}),
{
preserveScroll: true,
},
);
};
const destroyServer = (): void => {
if (!window.confirm(`Delete ${props.server.name}?`)) {
return;
}
router.delete(
route("servers.destroy", {
organisation: props.server.organisation_id,
server: props.server.id,
}),
);
};
const healServer = (): void => {
router.post(
route("servers.heal", {
organisation: props.server.organisation_id,
server: props.server.id,
}),
);
};
</script>
<template>
@@ -70,6 +134,7 @@ watch(counter, () => {
<div class="leading-none opacity-40">
{{ server.ipv4 }} &bull; {{ server.ipv6 }}
</div>
<Button size="sm" variant="destructive" @click="destroyServer">Delete</Button>
</div>
<template v-if="server.status === 'active'">
@@ -139,58 +204,99 @@ watch(counter, () => {
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
<Card>
<CardContent class="py-4">
<OperationTimeline :operations="server.service_operations" show-target />
</CardContent>
</Card>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Firewall</CardTitle>
<CardDescription>Rules Keystone knows about for this server.</CardDescription>
</CardHeader>
<CardContent class="grid gap-4">
<form class="grid gap-3 rounded-md border p-3" @submit.prevent="addFirewallRule">
<div class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end">
<div class="grid gap-2">
<Label for="firewall_type">Action</Label>
<select
id="firewall_type"
v-model="firewallForm.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
required
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
<InputError :message="firewallForm.errors.type" />
</div>
<div class="grid gap-2">
<Label for="firewall_ports">Ports</Label>
<Input
id="firewall_ports"
v-model="firewallForm.ports"
placeholder="80/tcp"
required
/>
<InputError :message="firewallForm.errors.ports" />
</div>
<div class="grid gap-2">
<Label for="firewall_from">Source</Label>
<Input
id="firewall_from"
v-model="firewallForm.from"
placeholder="any or CIDR"
/>
<InputError :message="firewallForm.errors.from" />
</div>
<Button type="submit" :disabled="firewallForm.processing">
<PlusIcon class="size-4" />
Add
</Button>
</div>
</form>
<div
v-for="operation in server.service_operations"
:key="operation.id"
class="flex gap-4"
v-for="rule in server.firewall_rules"
:key="rule.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div class="w-48 leading-none">{{ operation.target.name }}</div>
<div class="w-full space-y-4">
<div
v-for="step in operation.steps"
:key="step.id"
class="flex items-center space-y-1"
>
<div class="flex-1">
<div class="text-sm font-semibold leading-none">
{{ step.name ?? "Unnamed Step" }}
</div>
<div v-if="step.error_logs">
<pre class="text-xs text-muted-foreground"
>{{
step.error_logs_excerpt.length !==
step.error_logs
? "... "
: ""
}}{{ step.error_logs_excerpt }}</pre
>
</div>
<div v-else-if="step.logs">
<pre class="text-xs text-muted-foreground"
>{{
step.logs_excerpt.length !== step.logs
? "... "
: ""
}}{{ step.logs_excerpt }}</pre
>
</div>
</div>
<div>
<Button
size="xs"
variant="link"
@click="
() => {
selectedStep = step;
}
"
>
View
</Button>
</div>
<div>
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div>
<div class="text-muted-foreground">
{{ rule.from ? `from ${rule.from}` : "any source" }} ·
{{ rule.status }}
</div>
</div>
<Button
size="iconxs"
variant="ghost"
:aria-label="`Remove ${rule.type} ${rule.ports}`"
@click="destroyFirewallRule(rule)"
>
<Trash2Icon class="size-3" />
</Button>
</div>
<div
v-if="server.firewall_rules.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No firewall rules recorded.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Private network</CardTitle>
<CardDescription>Provider network membership.</CardDescription>
</CardHeader>
<CardContent class="text-sm">
<div v-if="server.network">
<div class="font-medium">{{ server.network.name }}</div>
<div class="text-muted-foreground">
{{ server.network.ip_range }} · {{ server.network.network_zone }}
</div>
</div>
<div v-else class="text-muted-foreground">No private network attached.</div>
</CardContent>
</Card>
</div>
@@ -201,7 +307,12 @@ watch(counter, () => {
<LoaderCircleIcon class="size-8 animate-spin" />
</div>
<div class="relative flex-grow">
<OperationTimeline
v-if="activeProvisionOperation"
:operations="[activeProvisionOperation]"
/>
<Transition
v-else
enter-active-class="transition duration-500 ease-in-out"
enter-from-class="opacity-0 -translate-x-4"
enter-to-class="opacity-100 translate-x-0"
@@ -213,37 +324,57 @@ watch(counter, () => {
</Transition>
</div>
</div>
<div>
<Button size="xs" disabled title="Services can be added after provisioning completes.">
<PlusIcon class="size-4" />
Add service
</Button>
</div>
</template>
<template v-else>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Server unavailable</CardTitle>
<CardDescription>
Service changes are disabled while this server is
{{ server.status }}.
</CardDescription>
</div>
<Button
v-if="server.status === 'provisioning-failed'"
variant="secondary"
@click="healServer"
>
<RefreshCwIcon class="size-4" />
Queue heal check
</Button>
</div>
</CardHeader>
<CardContent v-if="server.operations?.length" class="pt-0">
<OperationTimeline :operations="server.operations" />
</CardContent>
<CardContent v-else-if="server.status === 'provisioning-failed'" class="pt-0">
<CardDescription>
Keystone no longer has the provider root password. The heal check uses
the managed SSH user and records the checks as a server operation.
</CardDescription>
</CardContent>
</Card>
</template>
<template> Something else </template>
<div v-if="$page.props.flash?.server_credentials" class="p-5">
<div class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
WILL NOT BE SHOWN AGAIN:
{{ $page.props.flash.server_credentials }}
</div>
<p class="text-sm text-muted-foreground">
Keystone uses its managed SSH key for subsequent operations. This password is
informational for initial access only.
</p>
</div>
<Dialog
:open="!!selectedStep"
@update:open="($event) => (!$event ? (selectedStep = null) : null)"
>
<DialogContent class="md:max-w-2xl">
<DialogHeader>
<DialogTitle>Logs for {{ selectedStep?.name }}</DialogTitle>
</DialogHeader>
<section v-if="selectedStep?.logs">
<h3 class="text-sm font-medium">Logs</h3>
<pre class="text-xs text-muted-foreground">{{ selectedStep?.logs }}</pre>
</section>
<section v-if="selectedStep?.error_logs">
<h3 class="text-sm font-medium">Error Logs</h3>
<pre class="max-w-full overflow-x-scroll text-xs text-muted-foreground">{{
selectedStep?.error_logs
}}</pre>
</section>
</DialogContent>
</Dialog>
<!-- {{ server }} -->
</div>
</AppLayout>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { PlayIcon, RefreshCwIcon, SquareIcon } from "lucide-vue-next";
const props = defineProps<{
server: Record<string, any>;
service: Record<string, any>;
replica: Record<string, any>;
}>();
const restartReplica = (): void => {
router.post(
route("service-replicas.restart", {
organisation: props.server.organisation_id,
server: props.server.id,
service: props.service.id,
replica: props.replica.id,
}),
);
};
const startReplica = (): void => {
router.post(
route("service-replicas.start", {
organisation: props.server.organisation_id,
server: props.server.id,
service: props.service.id,
replica: props.replica.id,
}),
);
};
const stopReplica = (): void => {
router.post(
route("service-replicas.stop", {
organisation: props.server.organisation_id,
server: props.server.id,
service: props.service.id,
replica: props.replica.id,
}),
);
};
</script>
<template>
<Head :title="replica.container_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: replica.container_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 flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">
{{ replica.container_name }}
</h2>
<Badge variant="outline">{{ replica.status }}</Badge>
<Badge :variant="replica.health_status === 'healthy' ? 'success' : 'secondary'">
{{ replica.health_status }}
</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ replica.internal_host }}:{{ replica.internal_port }}
<span v-if="replica.public_port"> · public {{ replica.public_port }}</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="secondary" @click="startReplica">
<PlayIcon class="size-4" />
Start
</Button>
<Button variant="secondary" @click="stopReplica">
<SquareIcon class="size-4" />
Stop
</Button>
<Button @click="restartReplica">
<RefreshCwIcon class="size-4" />
Restart
</Button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Runtime</CardTitle>
<CardDescription>Container and image details.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<div>Container ID: {{ replica.container_id ?? "not captured" }}</div>
<div>Image digest: {{ replica.image_digest ?? "not captured" }}</div>
<div>CPU limit: {{ replica.cpu_limit ?? "default" }}</div>
<div>Memory MB: {{ replica.memory_limit_mb ?? "default" }}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Service</CardTitle>
</CardHeader>
<CardContent class="grid gap-2 text-sm">
<Link
:href="
route('services.show', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
})
"
class="font-medium hover:underline"
>
{{ service.name }}
</Link>
<div class="text-muted-foreground">
{{ service.type }} · {{ service.category }}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="replica.operations" />
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
defineProps<{
server: Record<string, any>;
service: Record<string, any>;
environments: Record<string, any>[];
}>();
const form = useForm({
name: "",
type: "manual",
environment_id: "",
status: "pending",
config: "{}",
});
</script>
<template>
<Head title="Create Slice" />
<AppLayout
:breadcrumbs="[
{
title: service.name,
href: route('services.show', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
}),
},
{ title: 'Create Slice' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.post(
route('service-slices.store', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Slice</h2>
<p class="mt-1 text-sm text-muted-foreground">
Advanced manual slice creation for service-level resources.
</p>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="type">Type</Label>
<Input id="type" v-model="form.type" required />
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2">
<Label for="status">Status</Label>
<Input id="status" v-model="form.status" required />
<InputError :message="form.errors.status" />
</div>
</div>
<div class="grid gap-2">
<Label for="environment_id">Environment</Label>
<select
id="environment_id"
v-model="form.environment_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option value="">Service-level</option>
<option v-for="environment in environments" :key="environment.id" :value="environment.id">
{{ environment.name }}
</option>
</select>
<InputError :message="form.errors.environment_id" />
</div>
<div class="grid gap-2">
<Label for="config">Config JSON</Label>
<textarea
id="config"
v-model="form.config"
class="min-h-28 rounded-md border border-input bg-transparent px-3 py-2 text-sm"
></textarea>
<InputError :message="form.errors.config" />
</div>
<div class="flex justify-end">
<Button type="submit" :disabled="form.processing">Create slice</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { PlusIcon } from "lucide-vue-next";
defineProps<{
server: Record<string, any>;
service: Record<string, any>;
slices: Record<string, any>[];
}>();
</script>
<template>
<Head :title="`${service.name} slices`" />
<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: 'Slices' },
]"
>
<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">Slices</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ service.name }} · {{ slices.length }} slices
</p>
</div>
<Button
:as="Link"
:href="
route('service-slices.create', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
})
"
>
<PlusIcon class="size-4" />
Add slice
</Button>
</div>
<div class="grid gap-4">
<Card v-for="slice in slices" :key="slice.id">
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>
<Link
:href="
route('service-slices.show', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
slice: slice.id,
})
"
class="hover:underline"
>
{{ slice.name }}
</Link>
</CardTitle>
<CardDescription>
{{ slice.environment?.application?.name ?? "Service" }}
<span v-if="slice.environment">/ {{ slice.environment.name }}</span>
</CardDescription>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">{{ slice.type }}</Badge>
<Badge :variant="slice.status === 'active' ? 'success' : 'secondary'">
{{ slice.status }}
</Badge>
<Badge variant="outline">{{ slice.attachments.length }} attachments</Badge>
</div>
</div>
</CardHeader>
<CardContent class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]">
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre>
<div>
<div class="mb-2 text-sm font-medium">Recent operations</div>
<OperationTimeline :operations="slice.operations" />
</div>
</CardContent>
</Card>
<Card v-if="slices.length === 0" class="border-dashed">
<CardHeader>
<CardTitle>No slices</CardTitle>
<CardDescription>Create a slice for a service-level capability or managed attachment.</CardDescription>
</CardHeader>
<CardContent>
<Button
:as="Link"
variant="secondary"
:href="
route('service-slices.create', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
})
"
>
<PlusIcon class="size-4" />
Add slice
</Button>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
defineProps<{
server: Record<string, any>;
service: Record<string, any>;
slice: Record<string, any>;
}>();
</script>
<template>
<Head :title="slice.name" />
<AppLayout
:breadcrumbs="[
{
title: service.name,
href: route('services.show', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
}),
},
{ title: slice.name },
]"
>
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ slice.name }}</h2>
<Badge variant="outline">{{ slice.type }}</Badge>
<Badge :variant="slice.status === 'active' ? 'success' : 'secondary'">
{{ slice.status }}
</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ slice.environment?.name ?? "Service-level slice" }}
</p>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>Credentials are stored encrypted and not revealed here.</CardDescription>
</CardHeader>
<CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Attachments</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<Link
v-for="attachment in slice.attachments"
:key="attachment.id"
:href="
route('environment-attachments.edit', {
organisation: $page.props.organisation.id,
application: attachment.environment.application.id,
environment: attachment.environment.id,
attachment: attachment.id,
})
"
class="rounded-md border p-3 text-sm hover:bg-muted/50"
>
<div class="font-medium">{{ attachment.role.replace('_', ' ') }}</div>
<div class="text-muted-foreground">
{{ attachment.environment.name }} ·
{{ attachment.env_prefix ?? "default prefix" }}
</div>
</Link>
<div
v-if="slice.attachments.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No attachments use this slice.
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Slice Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="slice.operations" />
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import RadioButton from "@/components/RadioButton.vue";
import { Button } from "@/components/ui/button";
@@ -18,9 +18,9 @@ import {
} from "lucide-vue-next";
import { watch } from "vue";
const props = defineProps({
services: Object,
});
const props = defineProps<{
services: Record<string, Record<string, string[]>>;
}>();
const form = useForm({
name: null,
@@ -29,7 +29,16 @@ const form = useForm({
version: null,
});
function getIcon(category) {
const deployPolicyDefaults = {
[ServiceCategory.APPLICATION]: "with_environment",
[ServiceCategory.DATABASE]: "dependency_only",
[ServiceCategory.CACHE]: "dependency_only",
[ServiceCategory.GATEWAY]: "manual_or_on_route_change",
[ServiceCategory.STORAGE]: "manual",
[ServiceCategory.BUILDER]: "manual",
};
function getIcon(category: string) {
switch (category) {
case ServiceCategory.DATABASE:
return DatabaseIcon;
@@ -46,7 +55,7 @@ function getIcon(category) {
}
}
function generateServiceName() {
function generateServiceName(): string {
let str = "";
if (form.category) {
@@ -89,27 +98,41 @@ watch([() => form.category, () => form.type, () => form.version], () => {
]"
>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<div class="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<div
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3"
role="radiogroup"
aria-label="Service category"
>
<RadioButton
v-for="(category, categoryKey) in ServiceCategory"
:key="category"
v-model="form.category"
:value="category"
name="category"
class="flex gap-3 py-3"
:described-by="`service-category-${category}-description`"
>
<component :is="getIcon(category)" class="size-5" />
<div>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">
{{ category }}
</h4>
<p class="text-sm">{{ serviceCategoryDescriptions[categoryKey] }}</p>
<p :id="`service-category-${category}-description`" class="text-sm">
{{ serviceCategoryDescriptions[categoryKey] }}
</p>
</div>
</RadioButton>
</div>
<div v-if="form.category" class="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<div
v-if="form.category"
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3"
role="radiogroup"
aria-label="Service type"
>
<RadioButton
v-for="service in services[form.category]"
v-for="service in services[form.category] ?? []"
:key="service.name"
v-model="form.type"
:value="service.name"
name="type"
@@ -119,11 +142,23 @@ watch([() => form.category, () => form.type, () => form.version], () => {
{{ service.name }}
</h4>
</RadioButton>
<div
v-if="!services[form.category]"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground md:col-span-2 lg:col-span-3"
>
No service drivers are configured for {{ form.category }} services yet.
</div>
</div>
<div v-if="form.type" class="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<div
v-if="form.type"
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3"
role="radiogroup"
aria-label="Service version"
>
<RadioButton
v-for="(version, versionKey) in services[form.category][form.type].versions"
:key="versionKey"
v-model="form.version"
:value="versionKey"
name="version"
@@ -135,6 +170,10 @@ watch([() => form.category, () => form.type, () => form.version], () => {
</RadioButton>
</div>
<div v-if="form.category" class="rounded-md border bg-muted/30 p-3 text-sm">
Default deploy policy: {{ deployPolicyDefaults[form.category]?.replace("_", " ") }}
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input

View File

@@ -1,22 +1,50 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps({
server: { type: Object, required: true },
service: { type: Object, required: true },
});
const props = defineProps<{
server: Record<string, any>;
service: Record<string, any>;
deployPolicies: string[];
}>();
const serviceConfig = props.service.config ?? {};
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,
deploy_policy: props.service.deploy_policy,
version_track: props.service.version_track,
available_image_digest: props.service.available_image_digest ?? "",
process_roles: props.service.process_roles?.join(", ") ?? "",
migration_mode: serviceConfig.migration_mode ?? "",
migration_timing: serviceConfig.migration_timing ?? "",
migration_command: serviceConfig.migration_command ?? "",
health_path: serviceConfig.health_path ?? "/up",
backup_enabled: Boolean(serviceConfig.backup_enabled),
backup_command: serviceConfig.backup_command ?? "",
});
const destroyService = (): void => {
if (!window.confirm(`Delete ${props.service.name}?`)) {
return;
}
router.delete(
route("services.destroy", {
organisation: props.server.organisation_id,
server: props.server.id,
service: props.service.id,
}),
);
};
</script>
<template>
@@ -47,7 +75,7 @@ const form = useForm({
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
class="flex h-full max-w-4xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('services.update', {
@@ -58,52 +86,161 @@ const form = useForm({
)
"
>
<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>
<h2 class="text-3xl font-bold tracking-tight">Edit {{ service.name }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
Deployment, runtime, migration, scheduler role, and health-check defaults.
</p>
</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>
<Card>
<CardHeader>
<CardTitle>Runtime</CardTitle>
</CardHeader>
<CardContent class="grid gap-4">
<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="flex justify-end">
<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>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Deployment</CardTitle>
<CardDescription>Stateful services can track available image updates.</CardDescription>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="deploy_policy">Deploy policy</Label>
<select
id="deploy_policy"
v-model="form.deploy_policy"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option v-for="policy in deployPolicies" :key="policy" :value="policy">
{{ policy.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.deploy_policy" />
</div>
<div class="grid gap-2">
<Label for="version_track">Version track</Label>
<Input id="version_track" v-model="form.version_track" />
<InputError :message="form.errors.version_track" />
</div>
<div class="grid gap-2 md:col-span-2">
<Label for="available_image_digest">Available image digest</Label>
<Input id="available_image_digest" v-model="form.available_image_digest" />
<InputError :message="form.errors.available_image_digest" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Roles, Migrations & Health</CardTitle>
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="process_roles">Process roles</Label>
<Input id="process_roles" v-model="form.process_roles" placeholder="web, scheduler" />
<InputError :message="form.errors.process_roles" />
</div>
<div class="grid gap-2">
<Label for="migration_mode">Migration mode</Label>
<Input id="migration_mode" v-model="form.migration_mode" />
<InputError :message="form.errors.migration_mode" />
</div>
<div class="grid gap-2">
<Label for="migration_timing">Migration timing</Label>
<Input id="migration_timing" v-model="form.migration_timing" />
<InputError :message="form.errors.migration_timing" />
</div>
<div class="grid gap-2">
<Label for="migration_command">Migration command</Label>
<Input id="migration_command" v-model="form.migration_command" />
<InputError :message="form.errors.migration_command" />
</div>
<div class="grid gap-2">
<Label for="health_path">Health path</Label>
<Input id="health_path" v-model="form.health_path" />
<InputError :message="form.errors.health_path" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backups</CardTitle>
<CardDescription>
Enabled backups can be run before guided stateful service updates.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-4">
<label class="flex items-center gap-2 text-sm">
<input
v-model="form.backup_enabled"
type="checkbox"
class="rounded border-input"
/>
Enable pre-update backup option
</label>
<InputError :message="form.errors.backup_enabled" />
<div class="grid gap-2">
<Label for="backup_command">Backup command</Label>
<Input
id="backup_command"
v-model="form.backup_command"
placeholder="pg_dump --format=custom app > /home/keystone/backups/pre-update.dump"
/>
<InputError :message="form.errors.backup_command" />
</div>
</CardContent>
</Card>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroyService">
Delete service
</Button>
<Button type="submit" :disabled="form.processing">Save</Button>
</div>
</form>

View File

@@ -1,15 +1,18 @@
<script setup>
<script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, 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";
import { PencilIcon, PlusIcon } from "lucide-vue-next";
const props = defineProps({
server: { type: Object, required: true },
service: { type: Object, required: true },
});
defineProps<{
server?: Record<string, any> | null;
service: Record<string, any>;
environment?: Record<string, any> | null;
application?: Record<string, any> | null;
}>();
</script>
<template>
@@ -17,17 +20,41 @@ const props = defineProps({
<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,
}),
},
...(application && environment
? [
{
title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }),
},
{
title: application.name,
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{
title: environment.name,
href: route('environments.show', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
}),
},
]
: [
{
title: 'Servers',
href: route('servers.index', { organisation: $page.props.organisation.id }),
},
{
title: server?.name ?? 'Server',
href: route('servers.show', {
organisation: $page.props.organisation.id,
server: server?.id,
}),
},
]),
{ title: service.name },
]"
>
@@ -43,6 +70,7 @@ const props = defineProps({
</p>
</div>
<Button
v-if="server"
:as="Link"
variant="secondary"
:href="
@@ -70,7 +98,21 @@ const props = defineProps({
:key="replica.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ replica.container_name }}</div>
<Link
v-if="server"
:href="
route('service-replicas.show', {
organisation: $page.props.organisation.id,
server: server?.id,
service: service.id,
replica: replica.id,
})
"
class="font-medium hover:underline"
>
{{ replica.container_name }}
</Link>
<div v-else class="font-medium">{{ replica.container_name }}</div>
<div class="text-muted-foreground">
{{ replica.status }} · {{ replica.health_status }}
</div>
@@ -80,7 +122,40 @@ const props = defineProps({
<Card>
<CardHeader>
<CardTitle>Slices</CardTitle>
<div class="flex items-center justify-between gap-3">
<CardTitle>Slices</CardTitle>
<div v-if="server" class="flex items-center gap-2">
<Button
:as="Link"
size="xs"
variant="ghost"
:href="
route('service-slices.index', {
organisation: $page.props.organisation.id,
server: server?.id,
service: service.id,
})
"
>
View all
</Button>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('service-slices.create', {
organisation: $page.props.organisation.id,
server: server?.id,
service: service.id,
})
"
>
<PlusIcon class="size-4" />
Add
</Button>
</div>
</div>
</CardHeader>
<CardContent class="grid gap-2">
<div
@@ -88,7 +163,21 @@ const props = defineProps({
:key="slice.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ slice.name }}</div>
<Link
v-if="server"
:href="
route('service-slices.show', {
organisation: $page.props.organisation.id,
server: server?.id,
service: service.id,
slice: slice.id,
})
"
class="font-medium hover:underline"
>
{{ slice.name }}
</Link>
<div v-else class="font-medium">{{ slice.name }}</div>
<div class="text-muted-foreground">{{ slice.type }}</div>
</div>
</CardContent>
@@ -98,20 +187,58 @@ const props = defineProps({
<CardHeader>
<CardTitle>Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="service.operations" />
</CardContent>
</Card>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Endpoints</CardTitle>
<CardDescription>Network endpoints registered for this service.</CardDescription>
</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"
v-for="endpoint in service.endpoints"
:key="endpoint.id"
class="rounded-md border p-3 text-sm"
>
<span>{{ operation.kind.replace("_", " ") }}</span>
<Badge
:variant="
operation.status === 'completed' ? 'success' : 'secondary'
"
>{{ operation.status.replace("_", " ") }}</Badge
>
<div class="font-medium">
{{ endpoint.scope }} · {{ endpoint.hostname }}:{{ endpoint.port }}
</div>
<div class="text-muted-foreground">
{{ endpoint.ip_address ?? "no IP" }} · priority
{{ endpoint.priority }} · {{ endpoint.health_status }}
</div>
</div>
<div
v-if="service.endpoints.length === 0"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
>
No endpoints registered.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Compose</CardTitle>
<CardDescription>Generated artifact location on the target server.</CardDescription>
</CardHeader>
<CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/services/{{ service.id }}/compose.yml</pre>
</CardContent>
</Card>
<Card v-if="service.type === 'caddy'">
<CardHeader>
<CardTitle>Caddyfile</CardTitle>
<CardDescription>Gateway route configuration generated on the server.</CardDescription>
</CardHeader>
<CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/gateway/Caddyfile</pre>
</CardContent>
</Card>
</div>

View File

@@ -1,32 +1,40 @@
<script setup>
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { Head, router, 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 props = defineProps<{
server: Record<string, any>;
service: Record<string, any>;
backupAvailable: boolean;
}>();
const form = useForm({
image_digest: props.service.available_image_digest ?? props.service.current_image_digest ?? "",
backup_requested: false,
confirmation: "",
});
const useAvailableDigest = () => {
form.image_digest = props.service.available_image_digest ?? form.image_digest;
};
const resolveLatestDigest = (): void => {
router.post(
route("service-updates.resolve", {
organisation: route().params.organisation,
server: props.server.id,
service: props.service.id,
}),
{},
{ preserveScroll: true },
);
};
</script>
<template>
@@ -71,6 +79,28 @@ const form = useForm({
place, starts the new image, and then runs a health check.
</div>
<div class="rounded-md border bg-muted/30 p-3 text-sm">
Latest known digest:
<code>{{ service.available_image_digest ?? "not resolved yet" }}</code>
<Button
size="xs"
variant="secondary"
class="ml-2"
@click="resolveLatestDigest"
>
Resolve latest minor
</Button>
<Button
v-if="service.available_image_digest"
size="xs"
variant="secondary"
class="ml-2"
@click="useAvailableDigest"
>
Use latest known
</Button>
</div>
<div class="grid gap-2">
<Label for="image_digest">Image digest</Label>
<Input
@@ -81,6 +111,12 @@ const form = useForm({
<InputError :message="form.errors.image_digest" />
</div>
<div class="grid gap-2">
<Label for="confirmation">Type {{ service.name }} to confirm downtime</Label>
<Input id="confirmation" v-model="form.confirmation" />
<InputError :message="form.errors.confirmation" />
</div>
<label v-if="backupAvailable" class="flex items-center gap-2 text-sm">
<input
v-model="form.backup_requested"
@@ -92,7 +128,7 @@ const form = useForm({
<div class="flex justify-end">
<Button
:disabled="form.processing"
:disabled="form.processing || form.confirmation !== service.name"
@click="
form.post(
route('service-updates.store', {

View File

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

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3";
const props = defineProps<{
sourceProvider: Record<string, any>;
sourceProviderTypes: string[];
}>();
const form = useForm({
name: props.sourceProvider.name,
type: props.sourceProvider.type,
url: props.sourceProvider.url ?? "",
});
const destroySourceProvider = (): void => {
if (!window.confirm(`Delete ${props.sourceProvider.name}?`)) {
return;
}
router.delete(
route("source-providers.destroy", {
organisation: props.sourceProvider.organisation_id,
source_provider: props.sourceProvider.id,
}),
);
};
</script>
<template>
<Head :title="`Edit ${sourceProvider.name}`" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: 'Edit Source Provider' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="
form.put(
route('source-providers.update', {
organisation: $page.props.organisation.id,
source_provider: sourceProvider.id,
}),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Edit Source Provider</h2>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" required />
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
v-for="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" placeholder="https://gitea.example.com" />
<InputError :message="form.errors.url" />
</div>
<div class="flex flex-wrap justify-between gap-2">
<Button type="button" variant="destructive" @click="destroySourceProvider">
Delete source provider
</Button>
<Button type="submit" :disabled="form.processing">Save source provider</Button>
</div>
</form>
</AppLayout>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-vue-next";
defineProps<{
sourceProviders: Record<string, any>[];
}>();
const destroySourceProvider = (sourceProvider: Record<string, any>): void => {
if (!window.confirm(`Delete ${sourceProvider.name}?`)) {
return;
}
router.delete(
route("source-providers.destroy", {
organisation: route().params.organisation,
source_provider: sourceProvider.id,
}),
);
};
</script>
<template>
<Head title="Source Providers" />
<AppLayout
:breadcrumbs="[
{
title: 'Organisation',
href: route('organisations.show', { organisation: $page.props.organisation.id }),
},
{ title: 'Source Providers' },
]"
>
<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">Source Providers</h2>
<p class="mt-1 text-sm text-muted-foreground">
Git hosts used to classify application repository access.
</p>
</div>
<Button
:as="Link"
:href="
route('source-providers.create', {
organisation: $page.props.organisation.id,
})
"
>
<PlusIcon class="size-4" />
Add source provider
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Configured Source Providers</CardTitle>
<CardDescription>{{ sourceProviders.length }} providers</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="sourceProvider in sourceProviders"
:key="sourceProvider.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ sourceProvider.name }}</span>
<Badge variant="outline">{{
sourceProvider.type?.replace("_", " ")
}}</Badge>
</div>
<div class="mt-1 text-muted-foreground">
{{ sourceProvider.url ?? "No base URL configured" }}
</div>
</div>
<div class="flex gap-1">
<Button
:as="Link"
size="iconxs"
variant="ghost"
:href="
route('source-providers.edit', {
organisation: $page.props.organisation.id,
source_provider: sourceProvider.id,
})
"
>
<PencilIcon class="size-3" />
</Button>
<Button
size="iconxs"
variant="ghost"
@click="destroySourceProvider(sourceProvider)"
>
<Trash2Icon class="size-3" />
</Button>
</div>
</div>
<div
v-if="sourceProviders.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No source providers configured.
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>