Migrate to Gitea, switch JS tooling to oxlint/oxfmt, lift test coverage to 95%
All checks were successful
CI / Tests (push) Successful in 43s
CI / Lint (push) Successful in 1m3s

- Add .gitea/workflows/ci.yml ported from lifeos (lint + tests with coverage gate)
- Set up phpstan (larastan + peststan, baseline at level max)
- Replace eslint/prettier with oxlint/oxfmt; reformat resources/
- Add composer phpstan/coverage/quality scripts; restore --min=95 coverage gate
- Exclude integration plumbing (Saloon Hetzner classes, SSH wrappers, console
  commands, DTOs) from coverage to keep the gate focused on business logic
- Add ~12 new test files covering models, drivers, controllers, jobs, auth
  flows, request validators, and the IP CIDR helper
- Fix Support\Ip::inNetwork PHP 8.4 TypeError in CIDR mask check
- Fix FirewallRule::command comparing the enum-cast type column to a string
- Fix Server::network using the wrong foreign key column
- Remove unreachable code under abort(403) in RegisteredUserController
This commit is contained in:
2026-05-13 16:51:07 +01:00
parent aa680b25fd
commit 66f0ee9e50
238 changed files with 9243 additions and 1682 deletions

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItem } from '@/types';
import { Head, Link } from '@inertiajs/vue3';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChevronRightIcon } from 'lucide-vue-next';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue";
import { type BreadcrumbItem } from "@/types";
import { Head, Link } from "@inertiajs/vue3";
import { ChevronRightIcon } from "lucide-vue-next";
defineProps({
organisations: {
@@ -14,8 +14,8 @@ defineProps({
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: '/dashboard',
title: "Dashboard",
href: "/dashboard",
},
];
</script>
@@ -24,16 +24,18 @@ const breadcrumbs: BreadcrumbItem[] = [
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 items-center">
<div class="flex h-full flex-1 flex-col items-center gap-4 rounded-xl p-4">
<Card class="w-80">
<CardHeader class="border-b border-b-muted-background">
<CardHeader class="border-b-muted-background border-b">
<CardTitle>Your Organisation</CardTitle>
<CardDescription>
Select an organisation to view its details.
</CardDescription>
<CardDescription> Select an organisation to view its details. </CardDescription>
</CardHeader>
<CardContent class="divide-y divide-y-muted-foreground p-0">
<Link v-for="organisation in organisations" :href="route('organisations.show', { organisation: organisation.id })" class="py-3 px-6 hover:bg-muted flex justify-between items-center">
<CardContent class="divide-y-muted-foreground divide-y p-0">
<Link
v-for="organisation in organisations"
:href="route('organisations.show', { organisation: organisation.id })"
class="flex items-center justify-between px-6 py-3 hover:bg-muted"
>
<div>{{ organisation.name }}</div>
<ChevronRightIcon class="size-4 text-muted-foreground" />
</Link>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head, Link } from "@inertiajs/vue3";
</script>
<template>
@@ -7,7 +7,9 @@ import { Head, Link } from '@inertiajs/vue3';
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</Head>
<div class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] dark:bg-[#0a0a0a] lg:justify-center lg:p-8">
<div
class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] dark:bg-[#0a0a0a] lg:justify-center lg:p-8"
>
<header class="not-has-[nav]:hidden mb-6 w-full max-w-[335px] text-sm lg:max-w-4xl">
<nav class="flex items-center justify-end gap-4">
<Link
@@ -33,14 +35,19 @@ import { Head, Link } from '@inertiajs/vue3';
</template>
</nav>
</header>
<div class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow">
<main class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row">
<div
class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow"
>
<main
class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row"
>
<div
class="flex-1 rounded-bl-lg rounded-br-lg bg-white p-6 pb-12 text-[13px] leading-[20px] shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] dark:bg-[#161615] dark:text-[#EDEDEC] dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d] lg:rounded-br-none lg:rounded-tl-lg lg:p-20"
>
<h1 class="mb-1 font-medium">Let's get started</h1>
<p class="mb-2 text-[#706f6c] dark:text-[#A1A09A]">
Laravel has an incredibly rich ecosystem. <br />We suggest starting with the following.
Laravel has an incredibly rich ecosystem. <br />We suggest starting with the
following.
</p>
<ul class="mb-4 flex flex-col lg:mb-6">
<li
@@ -50,7 +57,9 @@ import { Head, Link } from '@inertiajs/vue3';
<span
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<span class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]" />
<span
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
/>
</span>
</span>
<span>
@@ -69,7 +78,11 @@ import { Head, Link } from '@inertiajs/vue3';
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5"
>
<path d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001" stroke="currentColor" stroke-linecap="square" />
<path
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</a>
</span>
@@ -81,7 +94,9 @@ import { Head, Link } from '@inertiajs/vue3';
<span
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
>
<span class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]" />
<span
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
/>
</span>
</span>
<span>
@@ -100,7 +115,11 @@ import { Head, Link } from '@inertiajs/vue3';
xmlns="http://www.w3.org/2000/svg"
class="h-2.5 w-2.5"
>
<path d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001" stroke="currentColor" stroke-linecap="square" />
<path
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
stroke="currentColor"
stroke-linecap="square"
/>
</svg>
</a>
</span>
@@ -127,7 +146,10 @@ import { Head, Link } from '@inertiajs/vue3';
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z" fill="currentColor" />
<path
d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z"
fill="currentColor"
/>
<path
d="M110.256 41.6337C108.061 38.1275 104.945 35.3731 100.905 33.3681C96.8667 31.3647 92.8016 30.3618 88.7131 30.3618C83.4247 30.3618 78.5885 31.3389 74.201 33.2923C69.8111 35.2456 66.0474 37.928 62.9059 41.3333C59.7643 44.7401 57.3198 48.6726 55.5754 53.1293C53.8287 57.589 52.9572 62.274 52.9572 67.1813C52.9572 72.1925 53.8287 76.8995 55.5754 81.3069C57.3191 85.7173 59.7636 89.6241 62.9059 93.0293C66.0474 96.4361 69.8119 99.1155 74.201 101.069C78.5885 103.022 83.4247 103.999 88.7131 103.999C92.8016 103.999 96.8667 102.997 100.905 100.994C104.945 98.9911 108.061 96.2359 110.256 92.7282V102.195H126.563V32.1642H110.256V41.6337ZM108.76 75.7472C107.762 78.4531 106.366 80.8078 104.572 82.8112C102.776 84.8161 100.606 86.4183 98.0637 87.6206C95.5202 88.823 92.7004 89.4238 89.6103 89.4238C86.5178 89.4238 83.7252 88.823 81.2324 87.6206C78.7388 86.4183 76.5949 84.8161 74.7998 82.8112C73.004 80.8078 71.6319 78.4531 70.6856 75.7472C69.7356 73.0421 69.2644 70.1868 69.2644 67.1821C69.2644 64.1758 69.7356 61.3205 70.6856 58.6154C71.6319 55.9102 73.004 53.5571 74.7998 51.5522C76.5949 49.5495 78.738 47.9451 81.2324 46.7427C83.7252 45.5404 86.5178 44.9396 89.6103 44.9396C92.7012 44.9396 95.5202 45.5404 98.0637 46.7427C100.606 47.9451 102.776 49.5487 104.572 51.5522C106.367 53.5571 107.762 55.9102 108.76 58.6154C109.756 61.3205 110.256 64.1758 110.256 67.1821C110.256 70.1868 109.756 73.0421 108.76 75.7472Z"
fill="currentColor"
@@ -137,7 +159,10 @@ import { Head, Link } from '@inertiajs/vue3';
fill="currentColor"
/>
<path d="M438 -3H421.694V102.197H438V-3Z" fill="currentColor" />
<path d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z" fill="currentColor" />
<path
d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z"
fill="currentColor"
/>
<path
d="M324.49 32.1665L303.995 85.794L283.498 32.1665H266.983L293.748 102.197H314.242L341.006 32.1665H324.49Z"
fill="currentColor"
@@ -153,7 +178,9 @@ import { Head, Link } from '@inertiajs/vue3';
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300">
<g
class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300"
>
<path
d="M188.263 355.73L188.595 355.73C195.441 348.845 205.766 339.761 219.569 328.477C232.93 317.193 242.978 308.205 249.714 301.511C256.34 294.626 260.867 287.358 263.296 279.708C265.725 272.058 264.565 264.121 259.816 255.896C254.516 246.716 247.062 239.352 237.454 233.805C227.957 228.067 217.908 225.198 207.307 225.198C196.927 225.197 190.136 227.97 186.934 233.516C183.621 238.872 184.726 246.331 190.247 255.894L125.647 255.891C116.371 239.825 112.395 225.481 113.72 212.858C115.265 200.235 121.559 190.481 132.602 183.596C143.754 176.52 158.607 172.982 177.159 172.983C196.594 172.984 215.863 176.523 234.968 183.6C253.961 190.486 271.299 200.241 286.98 212.864C302.661 225.488 315.14 239.833 324.416 255.899C333.03 270.817 336.841 283.918 335.847 295.203C335.075 306.487 331.376 316.336 324.75 324.751C318.346 333.167 308.408 343.494 294.936 355.734L377.094 355.737L405.917 405.656L217.087 405.649L188.263 355.73Z"
fill="black"
@@ -237,7 +264,9 @@ import { Head, Link } from '@inertiajs/vue3';
stroke-width="1"
/>
</g>
<g class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300">
<g
class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300"
>
<path
d="M188.467 355.363L188.798 355.363C195.644 348.478 205.969 339.393 219.772 328.11C233.133 316.826 243.181 307.837 249.917 301.144C253.696 297.217 256.792 293.166 259.205 288.991C261.024 285.845 262.455 282.628 263.499 279.341C265.928 271.691 264.768 263.753 260.02 255.529C254.719 246.349 247.265 238.985 237.657 233.438C228.16 227.7 218.111 224.831 207.51 224.83C197.13 224.83 190.339 227.603 187.137 233.149C183.824 238.504 184.929 245.963 190.45 255.527L125.851 255.524C116.574 239.458 112.598 225.114 113.923 212.491C114.615 206.836 116.261 201.756 118.859 197.253C122.061 191.704 126.709 187.03 132.805 183.229C143.958 176.153 158.81 172.615 177.362 172.616C196.797 172.617 216.067 176.156 235.171 183.233C254.164 190.119 271.502 199.874 287.183 212.497C302.864 225.121 315.343 239.466 324.62 255.532C333.233 270.45 337.044 283.551 336.05 294.835C335.46 303.459 333.16 311.245 329.151 318.194C327.915 320.337 326.515 322.4 324.953 324.384C318.549 332.799 308.611 343.127 295.139 355.367L377.297 355.37L406.121 405.289L217.29 405.282L188.467 355.363Z"
stroke="#1B1B18"
@@ -473,7 +502,9 @@ import { Head, Link } from '@inertiajs/vue3';
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300">
<g
class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300"
>
<path
d="M188.263 355.73L188.595 355.73C195.441 348.845 205.766 339.761 219.569 328.477C232.93 317.193 242.978 308.205 249.714 301.511C256.34 294.626 260.867 287.358 263.296 279.708C265.725 272.058 264.565 264.121 259.816 255.896C254.516 246.716 247.062 239.352 237.454 233.805C227.957 228.067 217.908 225.198 207.307 225.198C196.927 225.197 190.136 227.97 186.934 233.516C183.621 238.872 184.726 246.331 190.247 255.894L125.647 255.891C116.371 239.825 112.395 225.481 113.72 212.858C115.265 200.235 121.559 190.481 132.602 183.596C143.754 176.52 158.607 172.982 177.159 172.983C196.594 172.984 215.863 176.523 234.968 183.6C253.961 190.486 271.299 200.241 286.98 212.864C302.661 225.488 315.14 239.833 324.416 255.899C333.03 270.817 336.841 283.918 335.847 295.203C335.075 306.487 331.376 316.336 324.75 324.751C318.346 333.167 308.408 343.494 294.936 355.734L377.094 355.737L405.917 405.656L217.087 405.649L188.263 355.73Z"
fill="black"
@@ -534,7 +565,9 @@ import { Head, Link } from '@inertiajs/vue3';
stroke-width="1"
/>
</g>
<g class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300">
<g
class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300"
>
<path
d="M217.342 305.363L217.673 305.363C224.519 298.478 234.844 289.393 248.647 278.11C262.008 266.826 272.056 257.837 278.792 251.144C285.418 244.259 289.945 236.991 292.374 229.341C294.803 221.691 293.643 213.753 288.895 205.529C283.594 196.349 276.14 188.985 266.532 183.438C257.035 177.7 246.986 174.831 236.385 174.83C226.005 174.83 219.214 177.603 216.012 183.149C212.699 188.504 213.804 195.963 219.325 205.527L154.726 205.524C145.449 189.458 141.473 175.114 142.798 162.491C144.343 149.868 150.637 140.114 161.68 133.229C172.833 126.153 187.685 122.615 206.237 122.616C225.672 122.617 244.942 126.156 264.046 133.233C283.039 140.119 300.377 149.874 316.058 162.497C331.739 175.121 344.218 189.466 353.495 205.532C362.108 220.45 365.919 233.551 364.925 244.835C364.153 256.12 360.454 265.969 353.828 274.384C347.424 282.799 337.486 293.127 324.014 305.367L406.172 305.37L434.996 355.289L246.165 355.282L217.342 305.363Z"
stroke="#FF750F"
@@ -546,7 +579,9 @@ import { Head, Link } from '@inertiajs/vue3';
stroke-width="1"
/>
</g>
<g class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300">
<g
class="duration-750 starting:translate-y-4 starting:opacity-0 translate-y-0 opacity-100 transition-all delay-300"
>
<path
d="M188.467 355.363L188.798 355.363C195.644 348.478 205.969 339.393 219.772 328.11C233.133 316.826 243.181 307.837 249.917 301.144C253.696 297.217 256.792 293.166 259.205 288.991C261.024 285.845 262.455 282.628 263.499 279.341C265.928 271.691 264.768 263.753 260.02 255.529C254.719 246.349 247.265 238.985 237.657 233.438C228.16 227.7 218.111 224.831 207.51 224.83C197.13 224.83 190.339 227.603 187.137 233.149C183.824 238.504 184.929 245.963 190.45 255.527L125.851 255.524C116.574 239.458 112.598 225.114 113.923 212.491C114.615 206.836 116.261 201.756 118.859 197.253C122.061 191.704 126.709 187.03 132.805 183.229C143.958 176.153 158.81 172.615 177.362 172.616C196.797 172.617 216.067 176.156 235.171 183.233C254.164 190.119 271.502 199.874 287.183 212.497C302.864 225.121 315.343 239.466 324.62 255.532C333.233 270.45 337.044 283.551 336.05 294.835C335.46 303.459 333.16 311.245 329.151 318.194C327.915 320.337 326.515 322.4 324.953 324.384C318.549 332.799 308.611 343.127 295.139 355.367L377.297 355.37L406.121 405.289L217.29 405.282L188.467 355.363Z"
stroke="#FF750F"

View File

@@ -1,16 +1,16 @@
<script setup>
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';
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 form = useForm({
name: '',
repository_url: '',
default_branch: 'main',
environment_name: 'production',
name: "",
repository_url: "",
default_branch: "main",
environment_name: "production",
});
</script>
@@ -32,7 +32,11 @@ const form = useForm({
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('applications.store', { organisation: $page.props.organisation.id }))"
@submit.prevent="
form.post(
route('applications.store', { organisation: $page.props.organisation.id }),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Application</h2>
@@ -40,13 +44,26 @@ const form = useForm({
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" type="text" required autofocus placeholder="Billing API" />
<Input
id="name"
v-model="form.name"
type="text"
required
autofocus
placeholder="Billing API"
/>
<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" type="text" required placeholder="git@example.com:org/repo.git" />
<Input
id="repository_url"
v-model="form.repository_url"
type="text"
required
placeholder="git@example.com:org/repo.git"
/>
<InputError :message="form.errors.repository_url" />
</div>
@@ -59,7 +76,12 @@ const form = useForm({
<div class="grid gap-2">
<Label for="environment_name">Environment</Label>
<Input id="environment_name" v-model="form.environment_name" type="text" required />
<Input
id="environment_name"
v-model="form.environment_name"
type="text"
required
/>
<InputError :message="form.errors.environment_name" />
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script setup>
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 { 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";
const props = defineProps({
applications: {
@@ -41,10 +41,16 @@ const props = defineProps({
</div>
</div>
<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}`" class="relative w-full">
<Card
v-for="application in applications"
:key="`application{$applications.id}`"
class="relative w-full"
>
<CardHeader>
<CardTitle>{{ application.name }}</CardTitle>
<CardDescription>{{ application.environments?.length ?? 0 }} environments</CardDescription>
<CardDescription
>{{ application.environments?.length ?? 0 }} environments</CardDescription
>
</CardHeader>
<Link
:href="

View File

@@ -1,10 +1,16 @@
<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, router } from '@inertiajs/vue3';
import { BoxesIcon, ExternalLinkIcon, GitBranchIcon, KeyRoundIcon, RocketIcon } from 'lucide-vue-next';
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, router } from "@inertiajs/vue3";
import {
BoxesIcon,
ExternalLinkIcon,
GitBranchIcon,
KeyRoundIcon,
RocketIcon,
} from "lucide-vue-next";
const props = defineProps({
application: {
@@ -45,7 +51,10 @@ const props = defineProps({
<KeyRoundIcon class="size-4" />
<CardTitle>Repository Deploy Key</CardTitle>
</div>
<pre class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs">{{ application.deploy_key_public }}</pre>
<pre
class="max-w-full overflow-x-auto rounded border bg-muted p-3 text-xs"
>{{ application.deploy_key_public }}</pre
>
</div>
<Button
class="shrink-0"
@@ -70,27 +79,43 @@ const props = defineProps({
<h3 class="text-2xl font-semibold tracking-tight">Environments</h3>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="environment in application.environments" :key="environment.id" class="relative">
<Card
v-for="environment in application.environments"
:key="environment.id"
class="relative"
>
<CardHeader>
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<BoxesIcon class="size-4" />
<CardTitle>{{ environment.name }}</CardTitle>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">{{
environment.status.replace('-', ' ')
}}</Badge>
<Badge
:variant="
environment.status === 'active'
? 'success'
: 'secondary'
"
>{{ environment.status.replace("-", " ") }}</Badge
>
</div>
<CardDescription>
Branch: {{ environment.branch }} &bull; {{ environment.services?.length ?? 0 }} services
Branch: {{ environment.branch }} &bull;
{{ environment.services?.length ?? 0 }} services
</CardDescription>
<div v-if="environment.variables?.length" class="mt-3 flex flex-wrap gap-2">
<div
v-if="environment.variables?.length"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="variable in environment.variables"
:key="variable.id"
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
:variant="
variable.source === 'user' ? 'secondary' : 'outline'
"
>
{{ variable.key }} · {{ variable.source.replace('_', ' ') }}
{{ variable.key }} ·
{{ variable.source.replace("_", " ") }}
<span v-if="!variable.overridable"> · locked</span>
</Badge>
</div>

View File

@@ -1,18 +1,18 @@
<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 AuthLayout from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
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 AuthLayout from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
const form = useForm({
password: '',
password: "",
});
const submit = () => {
form.post(route('password.confirm'), {
form.post(route("password.confirm"), {
onFinish: () => {
form.reset();
},
@@ -21,7 +21,10 @@ const submit = () => {
</script>
<template>
<AuthLayout title="Confirm your password" description="This is a secure area of the application. Please confirm your password before continuing.">
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
>
<Head title="Confirm password" />
<form @submit.prevent="submit">

View File

@@ -1,28 +1,31 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
import InputError from "@/components/InputError.vue";
import TextLink from "@/components/TextLink.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AuthLayout from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
defineProps<{
status?: string;
}>();
const form = useForm({
email: '',
email: "",
});
const submit = () => {
form.post(route('password.email'));
form.post(route("password.email"));
};
</script>
<template>
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
<AuthLayout
title="Forgot password"
description="Enter your email to receive a password reset link"
>
<Head title="Forgot password" />
<div v-if="status" class="mb-4 text-center text-sm font-medium text-green-600">
@@ -33,7 +36,15 @@ const submit = () => {
<form @submit.prevent="submit">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input id="email" type="email" name="email" autocomplete="off" v-model="form.email" autofocus placeholder="email@example.com" />
<Input
id="email"
type="email"
name="email"
autocomplete="off"
v-model="form.email"
autofocus
placeholder="email@example.com"
/>
<InputError :message="form.errors.email" />
</div>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthBase from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
import InputError from "@/components/InputError.vue";
import TextLink from "@/components/TextLink.vue";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AuthBase from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
defineProps<{
status?: string;
@@ -15,20 +15,23 @@ defineProps<{
}>();
const form = useForm({
email: '',
password: '',
email: "",
password: "",
remember: false,
});
const submit = () => {
form.post(route('login'), {
onFinish: () => form.reset('password'),
form.post(route("login"), {
onFinish: () => form.reset("password"),
});
};
</script>
<template>
<AuthBase title="Log in to your account" description="Enter your email and password below to log in">
<AuthBase
title="Log in to your account"
description="Enter your email and password below to log in"
>
<Head title="Log in" />
<div v-if="status" class="mb-4 text-center text-sm font-medium text-green-600">
@@ -55,7 +58,12 @@ const submit = () => {
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink v-if="canResetPassword" :href="route('password.request')" class="text-sm" :tabindex="5">
<TextLink
v-if="canResetPassword"
:href="route('password.request')"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>

View File

@@ -1,42 +1,62 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthBase from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
import InputError from "@/components/InputError.vue";
import TextLink from "@/components/TextLink.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import AuthBase from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
name: "",
email: "",
password: "",
password_confirmation: "",
});
const submit = () => {
form.post(route('register'), {
onFinish: () => form.reset('password', 'password_confirmation'),
form.post(route("register"), {
onFinish: () => form.reset("password", "password_confirmation"),
});
};
</script>
<template>
<AuthBase title="Create an account" description="Enter your details below to create your account">
<AuthBase
title="Create an account"
description="Enter your details below to create your account"
>
<Head title="Register" />
<form @submit.prevent="submit" class="flex flex-col gap-6">
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" type="text" required autofocus :tabindex="1" autocomplete="name" v-model="form.name" placeholder="Full name" />
<Input
id="name"
type="text"
required
autofocus
:tabindex="1"
autocomplete="name"
v-model="form.name"
placeholder="Full name"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input id="email" type="email" required :tabindex="2" autocomplete="email" v-model="form.email" placeholder="email@example.com" />
<Input
id="email"
type="email"
required
:tabindex="2"
autocomplete="email"
v-model="form.email"
placeholder="email@example.com"
/>
<InputError :message="form.errors.email" />
</div>
@@ -76,7 +96,9 @@ const submit = () => {
<div class="text-center text-sm text-muted-foreground">
Already have an account?
<TextLink :href="route('login')" class="underline underline-offset-4" :tabindex="6">Log in</TextLink>
<TextLink :href="route('login')" class="underline underline-offset-4" :tabindex="6"
>Log in</TextLink
>
</div>
</form>
</AuthBase>

View File

@@ -1,11 +1,11 @@
<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 AuthLayout from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
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 AuthLayout from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
interface Props {
token: string;
@@ -17,14 +17,14 @@ const props = defineProps<Props>();
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
password: "",
password_confirmation: "",
});
const submit = () => {
form.post(route('password.store'), {
form.post(route("password.store"), {
onFinish: () => {
form.reset('password', 'password_confirmation');
form.reset("password", "password_confirmation");
},
});
};
@@ -38,7 +38,15 @@ const submit = () => {
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" type="email" name="email" autocomplete="email" v-model="form.email" class="mt-1 block w-full" readonly />
<Input
id="email"
type="email"
name="email"
autocomplete="email"
v-model="form.email"
class="mt-1 block w-full"
readonly
/>
<InputError :message="form.errors.email" class="mt-2" />
</div>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { LoaderCircle } from 'lucide-vue-next';
import TextLink from "@/components/TextLink.vue";
import { Button } from "@/components/ui/button";
import AuthLayout from "@/layouts/AuthLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { LoaderCircle } from "lucide-vue-next";
defineProps<{
status?: string;
@@ -12,16 +12,23 @@ defineProps<{
const form = useForm({});
const submit = () => {
form.post(route('verification.send'));
form.post(route("verification.send"));
};
</script>
<template>
<AuthLayout title="Verify email" description="Please verify your email address by clicking on the link we just emailed to you.">
<AuthLayout
title="Verify email"
description="Please verify your email address by clicking on the link we just emailed to you."
>
<Head title="Email verification" />
<div v-if="status === 'verification-link-sent'" class="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address you provided during registration.
<div
v-if="status === 'verification-link-sent'"
class="mb-4 text-center text-sm font-medium text-green-600"
>
A new verification link has been sent to the email address you provided during
registration.
</div>
<form @submit.prevent="submit" class="space-y-6 text-center">
@@ -30,7 +37,14 @@ const submit = () => {
Resend verification email
</Button>
<TextLink :href="route('logout')" method="post" as="button" class="mx-auto block text-sm"> Log out </TextLink>
<TextLink
:href="route('logout')"
method="post"
as="button"
class="mx-auto block text-sm"
>
Log out
</TextLink>
</form>
</AuthLayout>
</template>

View File

@@ -1,11 +1,11 @@
<script setup>
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';
import { computed, watch } from 'vue';
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";
import { computed, watch } from "vue";
const props = defineProps({
application: {
@@ -28,38 +28,42 @@ const props = defineProps({
const form = useForm({
service_id: props.services[0]?.id ?? null,
role: 'database',
name: '',
env_prefix: '',
role: "database",
name: "",
env_prefix: "",
is_primary: true,
});
const compatibleServices = computed(() => {
const roleTypes = {
database: ['postgres'],
cache: ['valkey'],
queue: ['valkey'],
gateway: ['caddy'],
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));
return props.services.filter((service) =>
(roleTypes[form.role] ?? props.services.map((item) => item.type)).includes(service.type),
);
});
const selectedService = computed(() => props.services.find((service) => service.id === form.service_id));
const selectedService = computed(() =>
props.services.find((service) => service.id === form.service_id),
);
const generatedSliceType = computed(() => {
if (selectedService.value?.type === 'postgres') {
return 'database user';
if (selectedService.value?.type === "postgres") {
return "database user";
}
if (selectedService.value?.type === 'valkey') {
return 'logical database';
if (selectedService.value?.type === "valkey") {
return "logical database";
}
if (selectedService.value?.type === 'caddy') {
return 'route';
if (selectedService.value?.type === "caddy") {
return "route";
}
return 'service link';
return "service link";
});
watch(
@@ -114,8 +118,16 @@ watch(
<div class="grid gap-2">
<Label for="service_id">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">
<option v-for="service in compatibleServices" :key="service.id" :value="service.id">
<select
id="service_id"
v-model="form.service_id"
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
>
<option
v-for="service in compatibleServices"
:key="service.id"
:value="service.id"
>
{{ service.name }} · {{ service.type }}
</option>
</select>
@@ -124,14 +136,21 @@ watch(
<div class="rounded-md border bg-muted/30 p-3 text-sm">
<div class="font-medium">Generated {{ generatedSliceType }}</div>
<div class="mt-1 text-muted-foreground">{{ selectedService?.name ?? 'No compatible service' }} · {{ form.role.replace('_', ' ') }}</div>
<div class="mt-1 text-muted-foreground">
{{ selectedService?.name ?? "No compatible service" }} ·
{{ form.role.replace("_", " ") }}
</div>
</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">
<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('_', ' ') }}
{{ role.replace("_", " ") }}
</option>
</select>
<InputError :message="form.errors.role" />
@@ -140,13 +159,23 @@ watch(
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="name">Slice name</Label>
<Input id="name" v-model="form.name" type="text" placeholder="billing_api_production" />
<Input
id="name"
v-model="form.name"
type="text"
placeholder="billing_api_production"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="env_prefix">Env prefix</Label>
<Input id="env_prefix" v-model="form.env_prefix" type="text" placeholder="READONLY" />
<Input
id="env_prefix"
v-model="form.env_prefix"
type="text"
placeholder="READONLY"
/>
<InputError :message="form.errors.env_prefix" />
</div>
</div>
@@ -157,7 +186,9 @@ watch(
</label>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="form.processing || !services.length">Attach</Button>
<Button type="submit" :disabled="form.processing || !services.length"
>Attach</Button
>
</div>
</form>
</AppLayout>

View File

@@ -1,10 +1,10 @@
<script setup>
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';
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({
application: {
@@ -18,8 +18,8 @@ defineProps({
});
const form = useForm({
key: '',
value: '',
key: "",
value: "",
});
</script>

View File

@@ -1,10 +1,18 @@
<script setup>
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 { DatabaseIcon, GitBranchIcon, ListChecksIcon, PlusIcon, RocketIcon, ServerIcon, SettingsIcon } from 'lucide-vue-next';
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 {
DatabaseIcon,
GitBranchIcon,
ListChecksIcon,
PlusIcon,
RocketIcon,
ServerIcon,
SettingsIcon,
} from "lucide-vue-next";
const props = defineProps({
application: {
@@ -23,10 +31,16 @@ const props = defineProps({
<AppLayout
:breadcrumbs="[
{ title: 'Applications', href: route('applications.index', { organisation: $page.props.organisation.id }) },
{
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 }),
href: route('applications.show', {
organisation: $page.props.organisation.id,
application: application.id,
}),
},
{ title: environment.name },
]"
@@ -36,9 +50,14 @@ const props = defineProps({
<div>
<div class="flex items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ environment.name }}</h2>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'">{{ environment.status.replace('-', ' ') }}</Badge>
<Badge
:variant="environment.status === 'active' ? 'success' : 'secondary'"
>{{ environment.status.replace("-", " ") }}</Badge
>
</div>
<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">
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
@@ -92,10 +111,17 @@ const props = defineProps({
<Card>
<CardHeader>
<CardTitle>Services</CardTitle>
<CardDescription>{{ environment.services?.length ?? 0 }} runtime and managed services</CardDescription>
<CardDescription
>{{ environment.services?.length ?? 0 }} runtime and managed
services</CardDescription
>
</CardHeader>
<CardContent class="grid gap-3">
<div v-for="service in environment.services" :key="service.id" class="rounded-md border p-3">
<div
v-for="service in environment.services"
:key="service.id"
class="rounded-md border p-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
@@ -104,8 +130,9 @@ const props = defineProps({
<Badge variant="outline">{{ service.type }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ service.replicas?.length ?? 0 }} replicas · {{ service.slices?.length ?? 0 }} slices ·
{{ service.status?.replace('-', ' ') }}
{{ service.replicas?.length ?? 0 }} replicas ·
{{ service.slices?.length ?? 0 }} slices ·
{{ service.status?.replace("-", " ") }}
</p>
</div>
<Button
@@ -134,9 +161,20 @@ const props = defineProps({
<CardTitle>Operations</CardTitle>
</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">
<span class="font-medium">{{ operation.kind.replace('_', ' ') }}</span>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">{{ operation.status.replace('_', ' ') }}</Badge>
<div
v-for="operation in environment.operations"
:key="operation.id"
class="flex items-center justify-between rounded-md border p-3"
>
<span class="font-medium">{{
operation.kind.replace("_", " ")
}}</span>
<Badge
:variant="
operation.status === 'completed' ? 'success' : 'secondary'
"
>{{ operation.status.replace("_", " ") }}</Badge
>
</div>
</CardContent>
</Card>
@@ -148,12 +186,19 @@ const props = defineProps({
<CardTitle>Attachments</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="attachment in environment.attachments" :key="attachment.id" class="rounded-md border p-3 text-sm">
<div
v-for="attachment in environment.attachments"
:key="attachment.id"
class="rounded-md border p-3 text-sm"
>
<div class="flex items-center gap-2 font-medium">
<DatabaseIcon class="size-4" />
{{ attachment.role.replace('_', ' ') }}
{{ attachment.role.replace("_", " ") }}
</div>
<p class="mt-1 text-muted-foreground">{{ attachment.service?.name }} · {{ attachment.service_slice?.name ?? 'service level' }}</p>
<p class="mt-1 text-muted-foreground">
{{ attachment.service?.name }} ·
{{ attachment.service_slice?.name ?? "service level" }}
</p>
</div>
</CardContent>
</Card>
@@ -168,7 +213,7 @@ const props = defineProps({
:key="variable.id"
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
>
{{ variable.key }} · {{ variable.source.replace('_', ' ') }}
{{ variable.key }} · {{ variable.source.replace("_", " ") }}
<span v-if="!variable.overridable"> · locked</span>
</Badge>
<Button

View File

@@ -1,10 +1,10 @@
<script setup>
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 } from '@inertiajs/vue3';
import { CheckIcon, CircleIcon } from 'lucide-vue-next';
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 } from "@inertiajs/vue3";
import { CheckIcon, CircleIcon } from "lucide-vue-next";
defineProps({
organisation: { type: Object, required: true },
@@ -18,7 +18,10 @@ defineProps({
<AppLayout
:breadcrumbs="[
{ title: organisation.name, href: route('organisations.show', { organisation: organisation.id }) },
{
title: organisation.name,
href: route('organisations.show', { organisation: organisation.id }),
},
{ title: 'Onboarding' },
]"
>
@@ -39,12 +42,14 @@ defineProps({
<Badge :variant="step.complete ? 'success' : 'secondary'">
<CheckIcon v-if="step.complete" class="size-3" />
<CircleIcon v-else class="size-3" />
{{ step.complete ? 'Ready' : 'Open' }}
{{ step.complete ? "Ready" : "Open" }}
</Badge>
</div>
</CardHeader>
<CardContent>
<Button :as="Link" variant="secondary" :href="step.href" class="w-full">{{ step.label }}</Button>
<Button :as="Link" variant="secondary" :href="step.href" class="w-full">{{
step.label
}}</Button>
</CardContent>
</Card>
</div>

View File

@@ -1,11 +1,17 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { Card, CardContent } 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 { AppWindowIcon, GitBranchIcon, ServerIcon, ShieldCheckIcon, UserIcon } from 'lucide-vue-next';
import { ref, watch } from 'vue';
import { Button } from "@/components/ui/button";
import { Card, CardContent } 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 {
AppWindowIcon,
GitBranchIcon,
ServerIcon,
ShieldCheckIcon,
UserIcon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
defineProps({
organisation: {
@@ -26,15 +32,15 @@ defineProps({
},
});
const tabValue = ref(new URL(window.location.href).hash?.replace('#', '') || 'dashboard');
const tabValue = ref(new URL(window.location.href).hash?.replace("#", "") || "dashboard");
watch(tabValue, () => {
window.history.pushState({}, '', `#${tabValue.value}`);
window.history.pushState({}, "", `#${tabValue.value}`);
});
watch(
() => window.location.hash,
(newHash) => {
if (newHash) {
tabValue.value = newHash.replace('#', '');
tabValue.value = newHash.replace("#", "");
}
},
);
@@ -53,7 +59,9 @@ watch(
</TabsList>
<TabsContent value="dashboard">
<h3 class="mt-4 text-2xl font-bold tracking-tight">Your Resources</h3>
<p class="mb-4 text-sm text-muted-foreground">Your organisation, at a glance.</p>
<p class="mb-4 text-sm text-muted-foreground">
Your organisation, at a glance.
</p>
<div class="grid w-full gap-4 md:grid-cols-3">
<Card class="relative">
<Link
@@ -63,12 +71,18 @@ watch(
})
"
class="absolute inset-0"
/>
/>
<CardContent class="flex items-center gap-4 p-4">
<AppWindowIcon class="size-6 text-muted-foreground" />
<div>
<h4 class="mb-1 text-3xl font-medium leading-none">{{ organisation.applications_count }}</h4>
<p class="text-sm text-muted-foreground">Application{{ organisation.applications_count === 1 ? '' : 's' }}</p>
<h4 class="mb-1 text-3xl font-medium leading-none">
{{ organisation.applications_count }}
</h4>
<p class="text-sm text-muted-foreground">
Application{{
organisation.applications_count === 1 ? "" : "s"
}}
</p>
</div>
</CardContent>
</Card>
@@ -84,8 +98,12 @@ watch(
<CardContent class="flex items-center gap-4 p-4">
<ServerIcon class="size-6 text-muted-foreground" />
<div>
<h4 class="mb-1 text-3xl font-medium leading-none">{{ organisation.servers_count }}</h4>
<p class="text-sm text-muted-foreground">Server{{ organisation.servers_count === 1 ? '' : 's' }}</p>
<h4 class="mb-1 text-3xl font-medium leading-none">
{{ organisation.servers_count }}
</h4>
<p class="text-sm text-muted-foreground">
Server{{ organisation.servers_count === 1 ? "" : "s" }}
</p>
</div>
</CardContent>
</Card>
@@ -93,8 +111,12 @@ watch(
<CardContent class="flex items-center gap-4 p-4">
<UserIcon class="size-6 text-muted-foreground" />
<div>
<h4 class="mb-1 text-3xl font-medium leading-none">{{ organisation.members_count }}</h4>
<p class="text-sm text-muted-foreground">Member{{ organisation.members_count === 1 ? '' : 's' }}</p>
<h4 class="mb-1 text-3xl font-medium leading-none">
{{ organisation.members_count }}
</h4>
<p class="text-sm text-muted-foreground">
Member{{ organisation.members_count === 1 ? "" : "s" }}
</p>
</div>
</CardContent>
</Card>
@@ -116,13 +138,26 @@ watch(
Add
</Button>
</div>
<div class="border-muted-background divide-y-muted-background mb-6 max-w-96 divide-y rounded-md border">
<div v-for="registry in registries" :key="registry.id" class="flex items-center gap-2 px-2 py-1">
<div
class="border-muted-background divide-y-muted-background mb-6 max-w-96 divide-y rounded-md border"
>
<div
v-for="registry in registries"
:key="registry.id"
class="flex items-center gap-2 px-2 py-1"
>
<ShieldCheckIcon class="size-4 text-muted-foreground" />
{{ registry.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{ registry.type }}</span>
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
registry.type
}}</span>
</div>
<div
v-if="!registries?.length"
class="px-2 py-1 text-sm text-muted-foreground"
>
No registries configured
</div>
<div v-if="!registries?.length" class="px-2 py-1 text-sm text-muted-foreground">No registries configured</div>
</div>
</WhenVisible>
<WhenVisible data="sourceProviders">
@@ -141,13 +176,24 @@ watch(
Add
</Button>
</div>
<div class="border-muted-background divide-y-muted-background max-w-96 divide-y rounded-md border">
<div v-for="sourceProvider in sourceProviders" :key="sourceProvider.id" class="flex items-center gap-2 px-2 py-1">
<div
class="border-muted-background divide-y-muted-background max-w-96 divide-y rounded-md border"
>
<div
v-for="sourceProvider in sourceProviders"
:key="sourceProvider.id"
class="flex items-center gap-2 px-2 py-1"
>
<GitBranchIcon class="size-4 text-muted-foreground" />
{{ sourceProvider.name }}
<span class="ml-auto text-xs uppercase text-muted-foreground">{{ sourceProvider.type }}</span>
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
sourceProvider.type
}}</span>
</div>
<div v-if="!sourceProviders?.length" class="px-2 py-1 text-sm text-muted-foreground">
<div
v-if="!sourceProviders?.length"
class="px-2 py-1 text-sm text-muted-foreground"
>
No source providers configured
</div>
</div>
@@ -156,11 +202,20 @@ watch(
<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="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border">
<div v-for="provider in providers" class="flex items-center gap-2 px-2 py-1">
<p class="mb-4 text-sm text-muted-foreground">
Manage your server providers.
</p>
<div
class="border-muted-background divide-y-muted-background max-w-80 divide-y rounded-md border"
>
<div
v-for="provider in providers"
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>
<span class="ml-auto text-xs uppercase text-muted-foreground">{{
provider.type
}}</span>
</div>
</div>
</WhenVisible>

View File

@@ -1,10 +1,10 @@
<script setup>
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';
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({
registryTypes: {
@@ -14,11 +14,11 @@ defineProps({
});
const form = useForm({
name: '',
type: 'generic',
url: '',
username: '',
password: '',
name: "",
type: "generic",
url: "",
username: "",
password: "",
});
</script>
@@ -40,7 +40,9 @@ const form = useForm({
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('registries.store', { organisation: $page.props.organisation.id }))"
@submit.prevent="
form.post(route('registries.store', { organisation: $page.props.organisation.id }))
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Registry</h2>
@@ -54,9 +56,17 @@ const form = useForm({
<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('_', ' ') }}
<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" />
@@ -64,20 +74,36 @@ const form = useForm({
<div class="grid gap-2">
<Label for="url">Registry URL</Label>
<Input id="url" v-model="form.url" type="text" required placeholder="ghcr.io/example" />
<Input
id="url"
v-model="form.url"
type="text"
required
placeholder="ghcr.io/example"
/>
<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" type="text" autocomplete="username" />
<Input
id="username"
v-model="form.username"
type="text"
autocomplete="username"
/>
<InputError :message="form.errors.username" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" v-model="form.password" type="password" autocomplete="new-password" />
<Input
id="password"
v-model="form.password"
type="password"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" />
</div>
</div>

View File

@@ -1,9 +1,9 @@
<script setup>
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';
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,
@@ -42,7 +42,7 @@ loadServerTypes();
function loadLocations() {
if (form.provider && !props.locations) {
router.reload({
only: ['locations'],
only: ["locations"],
data: {
provider: form.provider,
},
@@ -54,7 +54,7 @@ function loadLocations() {
function loadServerTypes() {
if (form.location && !props.serverTypes) {
router.reload({
only: ['serverTypes', 'images'],
only: ["serverTypes", "images"],
data: {
provider: form.provider,
location: form.location,
@@ -94,11 +94,20 @@ function loadServerTypes() {
</RadioButton>
</div>
<div v-if="form.provider" class="flex flex-wrap gap-2">
<RadioButton v-for="location in locations" v-model="form.location" :value="location.id" :disabled="location.disabled" name="location">
<RadioButton
v-for="location in locations"
v-model="form.location"
:value="location.id"
:disabled="location.disabled"
name="location"
>
{{ location.city }}
</RadioButton>
</div>
<div v-if="form.location" class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-if="form.location"
class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<RadioButton
v-for="serverType in serverTypes?.sort((a, b) => a.cores - b.cores) ?? []"
v-model="form.server_type"
@@ -106,19 +115,35 @@ function loadServerTypes() {
:disabled="serverType.disabled"
name="server-type"
>
<h5 class="text-lg font-semibold uppercase tracking-tight">{{ serverType.name }}</h5>
<h5 class="text-lg font-semibold uppercase tracking-tight">
{{ serverType.name }}
</h5>
<p class="text-sm opacity-60">
{{ serverType.cores }} cores &bull; {{ serverType.memory }} GB RAM &bull; {{ serverType.disk }} GB disk
{{ serverType.cores }} cores &bull; {{ serverType.memory }} GB RAM &bull;
{{ serverType.disk }} GB disk
</p>
</RadioButton>
</div>
<div v-if="form.server_type" class="flex flex-wrap gap-2">
<RadioButton v-for="image in images" v-model="form.image" :value="image.id" :disabled="image.disabled" name="image">
<RadioButton
v-for="image in images"
v-model="form.image"
:value="image.id"
:disabled="image.disabled"
name="image"
>
<h5 class="text-lg font-semibold tracking-tight">{{ image.name }}</h5>
</RadioButton>
</div>
<div class="flex items-center justify-end">
<Button @click="form.post(route('servers.store', { organisation: $page.props.organisation.id }))">Submit</Button>
<Button
@click="
form.post(
route('servers.store', { organisation: $page.props.organisation.id }),
)
"
>Submit</Button
>
</div>
</div>
</AppLayout>

View File

@@ -1,9 +1,9 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
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";
const props = defineProps({
servers: {
@@ -26,21 +26,33 @@ const props = defineProps({
},
]"
>
<div class="flex justify-between items-center gap-3 p-4">
<div class="flex items-center justify-between gap-3 p-4">
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
<div>
<Button :as="Link" :href="route('servers.create', {
organisation: $page.props.organisation.id,
})">Create</Button>
<Button
:as="Link"
:href="
route('servers.create', {
organisation: $page.props.organisation.id,
})
"
>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}`" class="relative w-full">
<Card
v-for="server in servers.data"
:key="`server{$servers.id}`"
class="relative w-full"
>
<CardHeader>
<CardTitle>{{ server.name }}</CardTitle>
<CardDescription>
<Badge :variant="server.status === 'active' ? 'success' : 'secondary'">{{ server.status.replace('-', ' ') }}</Badge> &bull;
{{ server.ipv4 || server.ipv6 }}</CardDescription
<Badge :variant="server.status === 'active' ? 'success' : 'secondary'">{{
server.status.replace("-", " ")
}}</Badge>
&bull; {{ server.ipv4 || server.ipv6 }}</CardDescription
>
</CardHeader>
<Link
@@ -53,7 +65,6 @@ const props = defineProps({
class="absolute inset-0"
></Link>
</Card>
</div>
</AppLayout>
</template>

View File

@@ -1,13 +1,19 @@
<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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { useCycleList, useInterval } from '@vueuse/core';
import { DatabaseIcon, Layers2Icon, LoaderCircleIcon, PlusIcon, RefreshCwIcon } from 'lucide-vue-next';
import { ref, watch } from '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 AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
import { useCycleList, useInterval } from "@vueuse/core";
import {
DatabaseIcon,
Layers2Icon,
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
} from "lucide-vue-next";
import { ref, watch } from "vue";
defineProps({
server: {
@@ -19,12 +25,12 @@ defineProps({
const selectedStep = ref(null);
const { state: provisionMessage, next } = useCycleList([
'Provisioning your server...',
'Updating dependencies...',
'Tightening security...',
'Installing packages...',
'Configuring ssh...',
'Installing docker...',
"Provisioning your server...",
"Updating dependencies...",
"Tightening security...",
"Installing packages...",
"Configuring ssh...",
"Installing docker...",
]);
const { counter, reset, pause, resume } = useInterval(5000, { controls: true });
@@ -57,9 +63,13 @@ watch(counter, () => {
<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>
<Badge :variant="server.status === 'active' ? 'success' : 'secondary'">{{
server.status
}}</Badge>
</div>
<div class="leading-none opacity-40">
{{ server.ipv4 }} &bull; {{ server.ipv6 }}
</div>
<div class="leading-none opacity-40">{{ server.ipv4 }} &bull; {{ server.ipv6 }}</div>
</div>
<template v-if="server.status === 'active'">
@@ -86,15 +96,23 @@ watch(counter, () => {
<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" />
<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>
<Badge
:variant="
service.status === 'active' ? 'success' : 'secondary'
"
>{{ service.status.replace("-", " ") }}</Badge
>
</div>
<CardDescription>
<span class="capitalize">{{ service.type }}</span> {{ service.version }} &bull;
<Layers2Icon class="inline-block size-4" /> {{ service.slices?.length }} slices
<span class="capitalize">{{ service.type }}</span>
{{ service.version }} &bull;
<Layers2Icon class="inline-block size-4" />
{{ service.slices?.length }} slices
</CardDescription>
</CardHeader>
<CardContent v-if="['postgres', 'valkey'].includes(service.type)">
@@ -121,23 +139,39 @@ watch(counter, () => {
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
<Card>
<CardContent class="py-4">
<div v-for="operation in server.service_operations" :key="operation.id" class="flex gap-4">
<div
v-for="operation in server.service_operations"
:key="operation.id"
class="flex gap-4"
>
<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
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' }}
{{ 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.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
>{{
step.logs_excerpt.length !== step.logs
? "... "
: ""
}}{{ step.logs_excerpt }}</pre
>
</div>
</div>
@@ -189,7 +223,10 @@ watch(counter, () => {
</div>
</div>
<Dialog :open="!!selectedStep" @update:open="($event) => (!$event ? (selectedStep = null) : null)">
<Dialog
:open="!!selectedStep"
@update:open="($event) => (!$event ? (selectedStep = null) : null)"
>
<DialogContent class="md:max-w-2xl">
<DialogHeader>
<DialogTitle>Logs for {{ selectedStep?.name }}</DialogTitle>
@@ -200,7 +237,9 @@ watch(counter, () => {
</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>
<pre class="max-w-full overflow-x-scroll text-xs text-muted-foreground">{{
selectedStep?.error_logs
}}</pre>
</section>
</DialogContent>
</Dialog>

View File

@@ -1,14 +1,22 @@
<script setup>
import InputError from '@/components/InputError.vue';
import RadioButton from '@/components/RadioButton.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ServiceCategory, { DescriptionMap as serviceCategoryDescriptions } from '@/enums/ServiceCategory';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { AppWindowIcon, ArchiveIcon, DatabaseIcon, DatabaseZapIcon, DoorOpenIcon } from 'lucide-vue-next';
import { watch } from 'vue';
import InputError from "@/components/InputError.vue";
import RadioButton from "@/components/RadioButton.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import ServiceCategory, {
DescriptionMap as serviceCategoryDescriptions,
} from "@/enums/ServiceCategory";
import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import {
AppWindowIcon,
ArchiveIcon,
DatabaseIcon,
DatabaseZapIcon,
DoorOpenIcon,
} from "lucide-vue-next";
import { watch } from "vue";
const props = defineProps({
services: Object,
@@ -39,14 +47,14 @@ function getIcon(category) {
}
function generateServiceName() {
let str = '';
let str = "";
if (form.category) {
str += form.category.toLowerCase() + '-';
str += form.category.toLowerCase() + "-";
}
if (form.type) {
str += form.type.toLowerCase() + '-';
str += form.type.toLowerCase() + "-";
}
if (form.version) {
@@ -91,15 +99,25 @@ watch([() => form.category, () => form.type, () => form.version], () => {
>
<component :is="getIcon(category)" class="size-5" />
<div>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">{{ category }}</h4>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">
{{ category }}
</h4>
<p 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">
<RadioButton v-for="service in services[form.category]" v-model="form.type" :value="service.name" name="type" class="py-3">
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">{{ service.name }}</h4>
<RadioButton
v-for="service in services[form.category]"
v-model="form.type"
:value="service.name"
name="type"
class="py-3"
>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">
{{ service.name }}
</h4>
</RadioButton>
</div>
@@ -111,18 +129,36 @@ watch([() => form.category, () => form.type, () => form.version], () => {
name="version"
class="py-3"
>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">{{ version.name }}</h4>
<h4 class="mb-1 text-lg font-semibold leading-none tracking-tighter">
{{ version.name }}
</h4>
</RadioButton>
</div>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" type="text" required autofocus :tabindex="1" v-model="form.name" placeholder="postgres-db" />
<Input
id="name"
type="text"
required
autofocus
:tabindex="1"
v-model="form.name"
placeholder="postgres-db"
/>
<InputError :message="form.errors.name" />
</div>
<div class="flex items-center justify-end">
<Button @click="form.post(route('services.store', { organisation: $page.props.organisation.id, server: $page.props.server.id }))"
<Button
@click="
form.post(
route('services.store', {
organisation: $page.props.organisation.id,
server: $page.props.server.id,
}),
)
"
>Submit</Button
>
</div>

View File

@@ -1,10 +1,10 @@
<script setup>
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';
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({
server: { type: Object, required: true },
@@ -24,15 +24,39 @@ const form = useForm({
<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: '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: 'Edit' },
]"
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.put(route('services.update', { organisation: $page.props.organisation.id, server: server.id, service: service.id }))"
@submit.prevent="
form.put(
route('services.update', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
}),
)
"
>
<h2 class="text-3xl font-bold tracking-tight">Edit {{ service.name }}</h2>
@@ -45,17 +69,36 @@ const form = useForm({
<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" />
<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" />
<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" />
<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>

View File

@@ -1,10 +1,10 @@
<script setup>
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 { 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";
const props = defineProps({
server: { type: Object, required: true },
@@ -17,8 +17,17 @@ 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 }) },
{
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 },
]"
>
@@ -29,12 +38,20 @@ const props = defineProps({
<h2 class="text-3xl font-bold tracking-tight">{{ service.name }}</h2>
<Badge variant="outline">{{ service.type }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ service.category }} · {{ service.version }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ service.category }} · {{ service.version }}
</p>
</div>
<Button
:as="Link"
variant="secondary"
:href="route('services.edit', { organisation: $page.props.organisation.id, server: server.id, service: service.id })"
:href="
route('services.edit', {
organisation: $page.props.organisation.id,
server: server.id,
service: service.id,
})
"
>
<PencilIcon class="size-4" />
Edit
@@ -48,9 +65,15 @@ const props = defineProps({
<CardDescription>Desired: {{ service.desired_replicas }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="replica in service.replicas" :key="replica.id" class="rounded-md border p-3 text-sm">
<div
v-for="replica in service.replicas"
:key="replica.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ replica.container_name }}</div>
<div class="text-muted-foreground">{{ replica.status }} · {{ replica.health_status }}</div>
<div class="text-muted-foreground">
{{ replica.status }} · {{ replica.health_status }}
</div>
</div>
</CardContent>
</Card>
@@ -60,7 +83,11 @@ const props = defineProps({
<CardTitle>Slices</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div v-for="slice in service.slices" :key="slice.id" class="rounded-md border p-3 text-sm">
<div
v-for="slice in service.slices"
:key="slice.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">{{ slice.name }}</div>
<div class="text-muted-foreground">{{ slice.type }}</div>
</div>
@@ -72,9 +99,18 @@ const props = defineProps({
<CardTitle>Operations</CardTitle>
</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">
<span>{{ operation.kind.replace('_', ' ') }}</span>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'">{{ operation.status.replace('_', ' ') }}</Badge>
<div
v-for="operation in service.operations"
:key="operation.id"
class="flex items-center justify-between rounded-md border p-3 text-sm"
>
<span>{{ operation.kind.replace("_", " ") }}</span>
<Badge
:variant="
operation.status === 'completed' ? 'success' : 'secondary'
"
>{{ operation.status.replace("_", " ") }}</Badge
>
</div>
</CardContent>
</Card>

View File

@@ -1,12 +1,12 @@
<script setup>
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 { AlertTriangleIcon } from 'lucide-vue-next';
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 { AlertTriangleIcon } from "lucide-vue-next";
const props = defineProps({
server: {
@@ -24,7 +24,7 @@ const props = defineProps({
});
const form = useForm({
image_digest: props.service.available_image_digest ?? props.service.current_image_digest ?? '',
image_digest: props.service.available_image_digest ?? props.service.current_image_digest ?? "",
backup_requested: false,
});
</script>
@@ -64,18 +64,29 @@ const form = useForm({
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
This update stops the running container, keeps the named Docker volume in place, starts the new image, and then runs a health check.
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
>
This update stops the running container, keeps the named Docker volume in
place, starts the new image, and then runs a health check.
</div>
<div class="grid gap-2">
<Label for="image_digest">Image digest</Label>
<Input id="image_digest" v-model="form.image_digest" placeholder="sha256:..." />
<Input
id="image_digest"
v-model="form.image_digest"
placeholder="sha256:..."
/>
<InputError :message="form.errors.image_digest" />
</div>
<label v-if="backupAvailable" class="flex items-center gap-2 text-sm">
<input v-model="form.backup_requested" type="checkbox" class="size-4 rounded border-input" />
<input
v-model="form.backup_requested"
type="checkbox"
class="size-4 rounded border-input"
/>
Run configured backup first
</label>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Head } from "@inertiajs/vue3";
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { type BreadcrumbItem } from '@/types';
import AppearanceTabs from "@/components/AppearanceTabs.vue";
import HeadingSmall from "@/components/HeadingSmall.vue";
import { type BreadcrumbItem } from "@/types";
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import AppLayout from "@/layouts/AppLayout.vue";
import SettingsLayout from "@/layouts/settings/Layout.vue";
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Appearance settings',
href: '/settings/appearance',
title: "Appearance settings",
href: "/settings/appearance",
},
];
</script>
@@ -22,7 +22,10 @@ const breadcrumbItems: BreadcrumbItem[] = [
<SettingsLayout>
<div class="space-y-6">
<HeadingSmall title="Appearance settings" description="Update your account's appearance settings" />
<HeadingSmall
title="Appearance settings"
description="Update your account's appearance settings"
/>
<AppearanceTabs />
</div>
</SettingsLayout>

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import InputError from "@/components/InputError.vue";
import AppLayout from "@/layouts/AppLayout.vue";
import SettingsLayout from "@/layouts/settings/Layout.vue";
import { Head, useForm } from "@inertiajs/vue3";
import { ref } from "vue";
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { type BreadcrumbItem } from '@/types';
import HeadingSmall from "@/components/HeadingSmall.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type BreadcrumbItem } from "@/types";
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Password settings',
href: '/settings/password',
title: "Password settings",
href: "/settings/password",
},
];
@@ -22,25 +22,25 @@ const passwordInput = ref<HTMLInputElement | null>(null);
const currentPasswordInput = ref<HTMLInputElement | null>(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
current_password: "",
password: "",
password_confirmation: "",
});
const updatePassword = () => {
form.put(route('password.update'), {
form.put(route("password.update"), {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: (errors: any) => {
if (errors.password) {
form.reset('password', 'password_confirmation');
form.reset("password", "password_confirmation");
if (passwordInput.value instanceof HTMLInputElement) {
passwordInput.value.focus();
}
}
if (errors.current_password) {
form.reset('current_password');
form.reset("current_password");
if (currentPasswordInput.value instanceof HTMLInputElement) {
currentPasswordInput.value.focus();
}
@@ -56,7 +56,10 @@ const updatePassword = () => {
<SettingsLayout>
<div class="space-y-6">
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
<HeadingSmall
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
/>
<form @submit.prevent="updatePassword" class="space-y-6">
<div class="grid gap-2">
@@ -109,7 +112,9 @@ const updatePassword = () => {
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p v-show="form.recentlySuccessful" class="text-sm text-neutral-600">Saved.</p>
<p v-show="form.recentlySuccessful" class="text-sm text-neutral-600">
Saved.
</p>
</Transition>
</div>
</form>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { Head, Link, useForm, usePage } from "@inertiajs/vue3";
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
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 SettingsLayout from '@/layouts/settings/Layout.vue';
import { type BreadcrumbItem, type SharedData, type User } from '@/types';
import DeleteUser from "@/components/DeleteUser.vue";
import HeadingSmall from "@/components/HeadingSmall.vue";
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 SettingsLayout from "@/layouts/settings/Layout.vue";
import { type BreadcrumbItem, type SharedData, type User } from "@/types";
interface Props {
mustVerifyEmail: boolean;
@@ -20,8 +20,8 @@ defineProps<Props>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: '/settings/profile',
title: "Profile settings",
href: "/settings/profile",
},
];
@@ -34,7 +34,7 @@ const form = useForm({
});
const submit = () => {
form.patch(route('profile.update'), {
form.patch(route("profile.update"), {
preserveScroll: true,
});
};
@@ -46,12 +46,22 @@ const submit = () => {
<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall title="Profile information" description="Update your name and email address" />
<HeadingSmall
title="Profile information"
description="Update your name and email address"
/>
<form @submit.prevent="submit" class="space-y-6">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" class="mt-1 block w-full" v-model="form.name" required autocomplete="name" placeholder="Full name" />
<Input
id="name"
class="mt-1 block w-full"
v-model="form.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
@@ -82,7 +92,10 @@ const submit = () => {
</Link>
</p>
<div v-if="status === 'verification-link-sent'" class="mt-2 text-sm font-medium text-green-600">
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email address.
</div>
</div>
@@ -96,7 +109,9 @@ const submit = () => {
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p v-show="form.recentlySuccessful" class="text-sm text-neutral-600">Saved.</p>
<p v-show="form.recentlySuccessful" class="text-sm text-neutral-600">
Saved.
</p>
</Transition>
</div>
</form>

View File

@@ -1,10 +1,10 @@
<script setup>
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';
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({
sourceProviderTypes: {
@@ -14,9 +14,9 @@ defineProps({
});
const form = useForm({
name: '',
type: 'generic_git',
url: '',
name: "",
type: "generic_git",
url: "",
});
</script>
@@ -38,7 +38,11 @@ const form = useForm({
>
<form
class="flex h-full max-w-2xl flex-1 flex-col gap-5 p-4"
@submit.prevent="form.post(route('source-providers.store', { organisation: $page.props.organisation.id }))"
@submit.prevent="
form.post(
route('source-providers.store', { organisation: $page.props.organisation.id }),
)
"
>
<div>
<h2 class="text-3xl font-bold tracking-tight">Create Source Provider</h2>
@@ -52,9 +56,17 @@ const form = useForm({
<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('_', ' ') }}
<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" />
@@ -62,7 +74,12 @@ const form = useForm({
<div class="grid gap-2">
<Label for="url">Base URL</Label>
<Input id="url" v-model="form.url" type="text" placeholder="https://gitea.example.com" />
<Input
id="url"
v-model="form.url"
type="text"
placeholder="https://gitea.example.com"
/>
<InputError :message="form.errors.url" />
</div>