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>
|
||||
Reference in New Issue
Block a user