wowowowowo
This commit is contained in:
@@ -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[] = [
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
149
resources/js/components/operations/OperationTimeline.vue
Normal file
149
resources/js/components/operations/OperationTimeline.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
142
resources/js/pages/applications/Edit.vue
Normal file
142
resources/js/pages/applications/Edit.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} •
|
||||
{{ 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>
|
||||
|
||||
90
resources/js/pages/build-artifacts/Index.vue
Normal file
90
resources/js/pages/build-artifacts/Index.vue
Normal 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>
|
||||
70
resources/js/pages/build-artifacts/Show.vue
Normal file
70
resources/js/pages/build-artifacts/Show.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
137
resources/js/pages/environment-attachments/Edit.vue
Normal file
137
resources/js/pages/environment-attachments/Edit.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
110
resources/js/pages/environment-variables/Edit.vue
Normal file
110
resources/js/pages/environment-variables/Edit.vue
Normal 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>
|
||||
225
resources/js/pages/environment-variables/Index.vue
Normal file
225
resources/js/pages/environment-variables/Index.vue
Normal 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 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>
|
||||
81
resources/js/pages/environments/Create.vue
Normal file
81
resources/js/pages/environments/Create.vue
Normal 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>
|
||||
213
resources/js/pages/environments/Edit.vue
Normal file
213
resources/js/pages/environments/Edit.vue
Normal 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>
|
||||
89
resources/js/pages/environments/Index.vue
Normal file
89
resources/js/pages/environments/Index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
124
resources/js/pages/gateway-routes/Create.vue
Normal file
124
resources/js/pages/gateway-routes/Create.vue
Normal 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>
|
||||
124
resources/js/pages/gateway-routes/Edit.vue
Normal file
124
resources/js/pages/gateway-routes/Edit.vue
Normal 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>
|
||||
175
resources/js/pages/gateway-routes/Index.vue
Normal file
175
resources/js/pages/gateway-routes/Index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
98
resources/js/pages/operations/Index.vue
Normal file
98
resources/js/pages/operations/Index.vue
Normal 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>
|
||||
116
resources/js/pages/operations/Show.vue
Normal file
116
resources/js/pages/operations/Show.vue
Normal 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>
|
||||
226
resources/js/pages/organisation-members/Index.vue
Normal file
226
resources/js/pages/organisation-members/Index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
77
resources/js/pages/providers/Create.vue
Normal file
77
resources/js/pages/providers/Create.vue
Normal 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>
|
||||
@@ -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: "",
|
||||
|
||||
118
resources/js/pages/registries/Edit.vue
Normal file
118
resources/js/pages/registries/Edit.vue
Normal 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>
|
||||
118
resources/js/pages/registries/Index.vue
Normal file
118
resources/js/pages/registries/Index.vue
Normal 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>
|
||||
118
resources/js/pages/registries/Show.vue
Normal file
118
resources/js/pages/registries/Show.vue
Normal 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>
|
||||
@@ -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 • {{ serverType.memory }} GB RAM •
|
||||
{{ 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} • {{ 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>
|
||||
|
||||
158
resources/js/pages/service-replicas/Show.vue
Normal file
158
resources/js/pages/service-replicas/Show.vue
Normal 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>
|
||||
108
resources/js/pages/service-slices/Create.vue
Normal file
108
resources/js/pages/service-slices/Create.vue
Normal 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>
|
||||
135
resources/js/pages/service-slices/Index.vue
Normal file
135
resources/js/pages/service-slices/Index.vue
Normal 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>
|
||||
100
resources/js/pages/service-slices/Show.vue
Normal file
100
resources/js/pages/service-slices/Show.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
99
resources/js/pages/source-providers/Edit.vue
Normal file
99
resources/js/pages/source-providers/Edit.vue
Normal 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>
|
||||
115
resources/js/pages/source-providers/Index.vue
Normal file
115
resources/js/pages/source-providers/Index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user