build logs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-24 14:59:20 +03:00
parent 579d1ad214
commit 9fbec77ac7
15 changed files with 1804 additions and 2 deletions

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowUpRight, History, PackagePlus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
history as buildHistory,
index as dockerBuilderIndex,
} from '@/routes/docker-builder';
type DockerImageBuildHistoryItem = {
id: number;
image: string;
status: string;
status_label: string;
successful: boolean | null;
exit_code: number | null;
environment_count: number;
queued_at_human: string | null;
duration: string | null;
dockerfile_preview: string;
output_preview: string | null;
};
defineProps<{
builds: DockerImageBuildHistoryItem[];
}>();
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Docker builder',
href: dockerBuilderIndex(),
},
{
title: 'Build history',
href: buildHistory(),
},
],
},
});
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';
}
</script>
<template>
<Head title="Build history" />
<div class="flex flex-1 flex-col gap-6 p-4">
<Heading
title="Build history"
description="Review saved Docker build jobs, the Dockerfiles they used, and the latest log output captured for each run."
/>
<div class="flex justify-end">
<Button as-child>
<Link :href="dockerBuilderIndex()">
<PackagePlus />
New build
</Link>
</Button>
</div>
<div
v-if="builds.length === 0"
class="rounded-xl border border-dashed border-border/70 p-10 text-center text-sm text-muted-foreground"
>
No Docker builds have been saved yet.
</div>
<div v-else class="grid gap-4 xl:grid-cols-2">
<Card
v-for="build in builds"
:key="build.id"
class="gap-0 overflow-hidden"
>
<CardHeader class="border-b">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<CardTitle class="text-base break-all">
{{ build.image }}
</CardTitle>
<CardDescription>
{{
build.queued_at_human === null
? 'Queued recently'
: `Queued ${build.queued_at_human}`
}}
</CardDescription>
</div>
<Badge :variant="statusVariant(build.status)">
{{ build.status_label }}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-4 pt-6">
<div class="grid gap-3 sm:grid-cols-3">
<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">
{{ build.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">
{{ build.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">
{{
build.exit_code === null
? 'Pending'
: build.exit_code
}}
</p>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-border/70 p-3">
<p
class="mb-2 text-xs text-muted-foreground uppercase"
>
Dockerfile
</p>
<pre
class="font-mono text-xs leading-6 whitespace-pre-wrap"
><code>{{ build.dockerfile_preview }}</code></pre>
</div>
<div class="rounded-lg border border-border/70 p-3">
<p
class="mb-2 text-xs text-muted-foreground uppercase"
>
Log preview
</p>
<pre
class="font-mono text-xs leading-6 whitespace-pre-wrap text-muted-foreground"
><code>{{
build.output_preview ?? 'No build output saved yet.'
}}</code></pre>
</div>
</div>
<div class="flex justify-end">
<Button variant="outline" as-child>
<Link
:href="
dockerBuilderIndex({
query: { build: build.id },
})
"
>
<History />
Open build
<ArrowUpRight />
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View File

@@ -0,0 +1,613 @@
<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>