382 lines
17 KiB
Vue
382 lines
17 KiB
Vue
<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 { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import AppLayout from "@/layouts/AppLayout.vue";
|
|
import { Head, Link, router, useForm } from "@inertiajs/vue3";
|
|
import { useCycleList, useInterval } from "@vueuse/core";
|
|
import {
|
|
DatabaseIcon,
|
|
Layers2Icon,
|
|
LoaderCircleIcon,
|
|
PlusIcon,
|
|
RefreshCwIcon,
|
|
Trash2Icon,
|
|
} from "lucide-vue-next";
|
|
import { computed, watch } from "vue";
|
|
|
|
const props = defineProps({
|
|
server: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
const { state: provisionMessage, next } = useCycleList([
|
|
"Provisioning your server...",
|
|
"Updating dependencies...",
|
|
"Tightening security...",
|
|
"Installing packages...",
|
|
"Configuring ssh...",
|
|
"Installing docker...",
|
|
]);
|
|
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>
|
|
<Head :title="server.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,
|
|
}),
|
|
},
|
|
]"
|
|
>
|
|
<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">{{ server.name }}</h2>
|
|
<div>
|
|
<Badge :variant="server.status === 'active' ? 'success' : 'secondary'">{{
|
|
server.status
|
|
}}</Badge>
|
|
</div>
|
|
<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'">
|
|
<div>
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<h3 class="text-2xl font-semibold tracking-tight">Services</h3>
|
|
<div>
|
|
<Button
|
|
:as="Link"
|
|
:href="
|
|
route('services.create', {
|
|
organisation: $page.props.organisation.id,
|
|
server: server.id,
|
|
})
|
|
"
|
|
size="xs"
|
|
>
|
|
<PlusIcon class="size-4" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
<Card v-for="service in server.services" :key="service.id">
|
|
<CardHeader>
|
|
<div class="flex items-center gap-2">
|
|
<DatabaseIcon
|
|
v-if="service.category === 'database'"
|
|
class="size-5 opacity-50"
|
|
/>
|
|
<CardTitle>{{ service.name }}</CardTitle>
|
|
<Badge
|
|
:variant="
|
|
service.status === 'active' ? 'success' : 'secondary'
|
|
"
|
|
>{{ service.status.replace("-", " ") }}</Badge
|
|
>
|
|
</div>
|
|
<CardDescription>
|
|
<span class="capitalize">{{ service.type }}</span>
|
|
{{ service.version }} •
|
|
<Layers2Icon class="inline-block size-4" />
|
|
{{ service.slices?.length }} slices
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent v-if="['postgres', 'valkey'].includes(service.type)">
|
|
<Button
|
|
:as="Link"
|
|
:href="
|
|
route('service-updates.create', {
|
|
organisation: $page.props.organisation.id,
|
|
server: server.id,
|
|
service: service.id,
|
|
})
|
|
"
|
|
size="xs"
|
|
variant="outline"
|
|
>
|
|
<RefreshCwIcon class="size-4" />
|
|
Update
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="mb-3 text-2xl font-semibold tracking-tight">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="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>
|
|
<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>
|
|
</template>
|
|
<template v-else-if="server.status === 'provisioning'">
|
|
<div class="flex items-center gap-4 py-6">
|
|
<div class="flex-0 flex-shrink">
|
|
<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"
|
|
leave-active-class="transition absolute left-0 duration-500 ease-in-out"
|
|
leave-from-class="opacity-100 translate-x-0"
|
|
leave-to-class="opacity-0 translate-x-4"
|
|
>
|
|
<div :key="provisionMessage">{{ provisionMessage }}</div>
|
|
</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>
|
|
|
|
<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>
|
|
|
|
<!-- {{ server }} -->
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|