614 lines
24 KiB
Vue
614 lines
24 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, useForm, useHttp } from '@inertiajs/vue3';
|
|
import { History, Plus, TerminalSquare, Trash2 } from 'lucide-vue-next';
|
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
import DockerImageBuilderController from '@/actions/App/Http/Controllers/DockerImageBuilderController';
|
|
import Heading from '@/components/Heading.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
history as buildHistory,
|
|
index as dockerBuilderIndex,
|
|
} from '@/routes/docker-builder';
|
|
|
|
type EnvironmentVariable = {
|
|
key: string;
|
|
value: string;
|
|
};
|
|
|
|
type DockerImageBuild = {
|
|
id: number;
|
|
image: string;
|
|
image_name: string;
|
|
image_tag: string;
|
|
dockerfile_content: string;
|
|
environment: EnvironmentVariable[];
|
|
environment_count: number;
|
|
status: string;
|
|
status_label: string;
|
|
successful: boolean | null;
|
|
exit_code: number | null;
|
|
build_output: string;
|
|
queued_at: string | null;
|
|
queued_at_human: string | null;
|
|
started_at: string | null;
|
|
started_at_human: string | null;
|
|
finished_at: string | null;
|
|
finished_at_human: string | null;
|
|
duration: string | null;
|
|
};
|
|
|
|
const dockerfilePlaceholder = `FROM alpine:3.20
|
|
RUN apk add --no-cache curl
|
|
CMD ["sh", "-c", "env && sleep infinity"]`;
|
|
|
|
const props = defineProps<{
|
|
build?: DockerImageBuild | null;
|
|
}>();
|
|
|
|
defineOptions({
|
|
layout: {
|
|
breadcrumbs: [
|
|
{
|
|
title: 'Docker builder',
|
|
href: dockerBuilderIndex(),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const form = useForm<{
|
|
dockerfile: string;
|
|
environment: EnvironmentVariable[];
|
|
image_name: string;
|
|
image_tag: string;
|
|
}>({
|
|
dockerfile: '',
|
|
environment: [{ key: '', value: '' }],
|
|
image_name: '',
|
|
image_tag: 'latest',
|
|
});
|
|
|
|
const http = useHttp();
|
|
const activeBuild = ref<DockerImageBuild | null>(props.build ?? null);
|
|
const isRefreshingBuild = ref(false);
|
|
const logViewport = ref<HTMLElement | null>(null);
|
|
|
|
let pollTimer: ReturnType<typeof window.setInterval> | null = null;
|
|
|
|
function addEnvironmentVariable(): void {
|
|
form.environment.push({
|
|
key: '',
|
|
value: '',
|
|
});
|
|
}
|
|
|
|
function removeEnvironmentVariable(index: number): void {
|
|
if (form.environment.length === 1) {
|
|
form.environment[0] = {
|
|
key: '',
|
|
value: '',
|
|
};
|
|
|
|
return;
|
|
}
|
|
|
|
form.environment.splice(index, 1);
|
|
}
|
|
|
|
function environmentFieldError(
|
|
index: number,
|
|
field: 'key' | 'value',
|
|
): string | undefined {
|
|
return form.errors[`environment.${index}.${field}`];
|
|
}
|
|
|
|
function buildLog(build: DockerImageBuild | null): string {
|
|
if (build === null) {
|
|
return 'Queue a build to start capturing Docker output.';
|
|
}
|
|
|
|
if (build.build_output !== '') {
|
|
return build.build_output;
|
|
}
|
|
|
|
if (build.status === 'queued') {
|
|
return 'Build is queued. Start `php artisan queue:work` if the worker is not already running.';
|
|
}
|
|
|
|
if (build.status === 'running') {
|
|
return 'Build started. Waiting for Docker to emit log output...';
|
|
}
|
|
|
|
return 'No build output was captured.';
|
|
}
|
|
|
|
function statusVariant(
|
|
status: string,
|
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
if (status === 'succeeded') {
|
|
return 'default';
|
|
}
|
|
|
|
if (status === 'failed') {
|
|
return 'destructive';
|
|
}
|
|
|
|
if (status === 'running') {
|
|
return 'secondary';
|
|
}
|
|
|
|
return 'outline';
|
|
}
|
|
|
|
function shouldPollBuild(build: DockerImageBuild | null): boolean {
|
|
return build !== null && ['queued', 'running'].includes(build.status);
|
|
}
|
|
|
|
function stopPolling(): void {
|
|
if (pollTimer !== null) {
|
|
window.clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function refreshBuild(): Promise<void> {
|
|
if (activeBuild.value === null || isRefreshingBuild.value) {
|
|
return;
|
|
}
|
|
|
|
isRefreshingBuild.value = true;
|
|
|
|
try {
|
|
const response = (await http.submit(
|
|
DockerImageBuilderController.show(activeBuild.value.id),
|
|
)) as {
|
|
build: DockerImageBuild;
|
|
};
|
|
|
|
activeBuild.value = response.build;
|
|
|
|
if (!shouldPollBuild(response.build)) {
|
|
stopPolling();
|
|
}
|
|
} catch {
|
|
stopPolling();
|
|
} finally {
|
|
isRefreshingBuild.value = false;
|
|
}
|
|
}
|
|
|
|
function startPolling(): void {
|
|
stopPolling();
|
|
|
|
if (!shouldPollBuild(activeBuild.value)) {
|
|
return;
|
|
}
|
|
|
|
pollTimer = window.setInterval(() => {
|
|
void refreshBuild();
|
|
}, 1000);
|
|
}
|
|
|
|
function submit(): void {
|
|
form.transform((data) => ({
|
|
...data,
|
|
dockerfile: data.dockerfile.trim(),
|
|
image_name: data.image_name.trim(),
|
|
image_tag: data.image_tag.trim(),
|
|
environment: data.environment
|
|
.map((variable) => ({
|
|
key: variable.key.trim(),
|
|
value: variable.value,
|
|
}))
|
|
.filter((variable) => variable.key !== '' || variable.value !== ''),
|
|
})).post(DockerImageBuilderController.store.url());
|
|
}
|
|
|
|
watch(
|
|
() => props.build,
|
|
(build) => {
|
|
activeBuild.value = build ?? null;
|
|
startPolling();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => activeBuild.value?.build_output,
|
|
async () => {
|
|
await nextTick();
|
|
|
|
if (logViewport.value === null) {
|
|
return;
|
|
}
|
|
|
|
logViewport.value.scrollTop = logViewport.value.scrollHeight;
|
|
},
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
stopPolling();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Head title="Docker builder" />
|
|
|
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
|
<Heading
|
|
title="Docker image builder"
|
|
description="Paste a Dockerfile, queue a build, and watch the saved build log update while the queue worker runs."
|
|
/>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(24rem,1fr)]">
|
|
<Card class="gap-0">
|
|
<CardHeader class="border-b">
|
|
<div
|
|
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
|
|
>
|
|
<div class="space-y-1">
|
|
<CardTitle>Build definition</CardTitle>
|
|
<CardDescription>
|
|
The build context contains the generated
|
|
Dockerfile only. Run `php artisan queue:work` so
|
|
queued builds can start and stream logs back
|
|
into the page.
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<Button variant="outline" as-child>
|
|
<Link :href="buildHistory()">
|
|
<History />
|
|
Build history
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-6 pt-6">
|
|
<form class="space-y-6" @submit.prevent="submit">
|
|
<div class="grid gap-4 md:grid-cols-2">
|
|
<div class="grid gap-2">
|
|
<Label for="image_name">Image name</Label>
|
|
<Input
|
|
id="image_name"
|
|
v-model="form.image_name"
|
|
autocomplete="off"
|
|
placeholder="ghcr.io/acme/my-image"
|
|
required
|
|
/>
|
|
<InputError :message="form.errors.image_name" />
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<Label for="image_tag">Tag</Label>
|
|
<Input
|
|
id="image_tag"
|
|
v-model="form.image_tag"
|
|
autocomplete="off"
|
|
placeholder="latest"
|
|
required
|
|
/>
|
|
<InputError :message="form.errors.image_tag" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<Label for="dockerfile">Dockerfile</Label>
|
|
<textarea
|
|
id="dockerfile"
|
|
v-model="form.dockerfile"
|
|
class="min-h-80 w-full rounded-md border border-input bg-transparent px-3 py-3 font-mono text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40"
|
|
:placeholder="dockerfilePlaceholder"
|
|
required
|
|
spellcheck="false"
|
|
/>
|
|
<InputError :message="form.errors.dockerfile" />
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div
|
|
class="flex items-center justify-between gap-3"
|
|
>
|
|
<div>
|
|
<Label>Environment variables</Label>
|
|
<p class="text-sm text-muted-foreground">
|
|
These are appended to the Dockerfile as
|
|
`ENV KEY="value"` lines before the job
|
|
runs.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
@click="addEnvironmentVariable"
|
|
>
|
|
<Plus />
|
|
Add variable
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="(
|
|
variable, indexValue
|
|
) in form.environment"
|
|
:key="indexValue"
|
|
class="rounded-lg border border-border/70 p-4"
|
|
>
|
|
<div
|
|
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start"
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label
|
|
:for="`environment_key_${indexValue}`"
|
|
>
|
|
Key
|
|
</Label>
|
|
<Input
|
|
:id="`environment_key_${indexValue}`"
|
|
v-model="variable.key"
|
|
autocomplete="off"
|
|
placeholder="APP_ENV"
|
|
/>
|
|
<InputError
|
|
:message="
|
|
environmentFieldError(
|
|
indexValue,
|
|
'key',
|
|
)
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<Label
|
|
:for="`environment_value_${indexValue}`"
|
|
>
|
|
Value
|
|
</Label>
|
|
<Input
|
|
:id="`environment_value_${indexValue}`"
|
|
v-model="variable.value"
|
|
autocomplete="off"
|
|
placeholder="production"
|
|
/>
|
|
<InputError
|
|
:message="
|
|
environmentFieldError(
|
|
indexValue,
|
|
'value',
|
|
)
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div class="md:pt-8">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
class="text-muted-foreground hover:text-foreground"
|
|
@click="
|
|
removeEnvironmentVariable(
|
|
indexValue,
|
|
)
|
|
"
|
|
>
|
|
<Trash2 />
|
|
<span class="sr-only">
|
|
Remove environment variable
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground"
|
|
>
|
|
Each build is saved with the Dockerfile, image name,
|
|
tag, environment variables, status, and log output.
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
:disabled="form.processing"
|
|
class="w-full md:w-auto"
|
|
>
|
|
<TerminalSquare />
|
|
{{
|
|
form.processing
|
|
? 'Queueing build...'
|
|
: 'Queue build'
|
|
}}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card class="gap-0">
|
|
<CardHeader class="border-b">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="space-y-1">
|
|
<CardTitle>
|
|
{{
|
|
activeBuild === null
|
|
? 'Latest build'
|
|
: activeBuild.image
|
|
}}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{{
|
|
activeBuild === null
|
|
? 'The selected build record and log output appear here.'
|
|
: 'Logs refresh automatically while the build is queued or running.'
|
|
}}
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<Badge
|
|
v-if="activeBuild !== null"
|
|
:variant="statusVariant(activeBuild.status)"
|
|
>
|
|
{{ activeBuild.status_label }}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-6 pt-6">
|
|
<template v-if="activeBuild !== null">
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<div class="rounded-lg border border-border/70 p-3">
|
|
<p
|
|
class="text-xs text-muted-foreground uppercase"
|
|
>
|
|
Queued
|
|
</p>
|
|
<p class="mt-1 text-sm font-medium">
|
|
{{
|
|
activeBuild.queued_at_human ??
|
|
'Just now'
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-border/70 p-3">
|
|
<p
|
|
class="text-xs text-muted-foreground uppercase"
|
|
>
|
|
Duration
|
|
</p>
|
|
<p class="mt-1 text-sm font-medium">
|
|
{{
|
|
activeBuild.duration ??
|
|
'Waiting to start'
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-border/70 p-3">
|
|
<p
|
|
class="text-xs text-muted-foreground uppercase"
|
|
>
|
|
Environment
|
|
</p>
|
|
<p class="mt-1 text-sm font-medium">
|
|
{{ activeBuild.environment_count }}
|
|
variables
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-border/70 p-3">
|
|
<p
|
|
class="text-xs text-muted-foreground uppercase"
|
|
>
|
|
Exit code
|
|
</p>
|
|
<p class="mt-1 text-sm font-medium">
|
|
{{
|
|
activeBuild.exit_code === null
|
|
? 'Pending'
|
|
: activeBuild.exit_code
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="activeBuild.environment.length > 0"
|
|
class="space-y-2"
|
|
>
|
|
<p class="text-sm font-medium">Saved environment</p>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="(
|
|
variable, indexValue
|
|
) in activeBuild.environment"
|
|
:key="`${activeBuild.id}-${indexValue}`"
|
|
class="rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-sm"
|
|
>
|
|
<span class="font-mono font-medium">
|
|
{{ variable.key }}
|
|
</span>
|
|
<span class="mx-2 text-muted-foreground">
|
|
=
|
|
</span>
|
|
<span
|
|
class="font-mono text-muted-foreground"
|
|
>
|
|
{{ variable.value }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<p class="text-sm font-medium">
|
|
Dockerfile snapshot
|
|
</p>
|
|
<pre
|
|
class="max-h-56 overflow-auto rounded-lg border border-border/70 bg-muted/20 p-4 font-mono text-xs leading-6 whitespace-pre-wrap"
|
|
><code>{{ activeBuild.dockerfile_content }}</code></pre>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<div
|
|
class="flex items-center justify-between gap-3"
|
|
>
|
|
<p class="text-sm font-medium">Build log</p>
|
|
<span
|
|
v-if="activeBuild.status === 'running'"
|
|
class="text-xs text-muted-foreground"
|
|
>
|
|
Updating every second
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
ref="logViewport"
|
|
class="max-h-[30rem] overflow-auto rounded-lg border border-border/70 bg-zinc-950 p-4 text-xs text-zinc-100"
|
|
>
|
|
<pre class="font-mono whitespace-pre-wrap">{{
|
|
buildLog(activeBuild)
|
|
}}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<Button variant="outline" as-child>
|
|
<Link :href="buildHistory()">
|
|
<History />
|
|
View full history
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<div
|
|
v-else
|
|
class="rounded-xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground"
|
|
>
|
|
No saved builds yet. Queue a Docker build to start
|
|
capturing logs and history.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</template>
|