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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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