-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(admin-ui): add job artifact download support and improve job list UX #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { createReadStream, promises as fsp } from "fs"; | ||
| import path from "path"; | ||
| import { Readable } from "stream"; | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| import type { AdminJob } from "@/lib/types"; | ||
| import { gatewayRequest } from "@/lib/gateway"; | ||
|
|
||
| export const runtime = "nodejs"; | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| type RouteParams = { | ||
| params: Promise<{ | ||
| name: string; | ||
| }>; | ||
| }; | ||
|
|
||
| const normalizeSource = (value?: string) => { | ||
| const key = value?.trim().toLowerCase(); | ||
| if (!key) { | ||
| return "git_tag"; | ||
| } | ||
| if (key === "git_tag" || key === "tarball_url" || key === "tarball_path") { | ||
| return key; | ||
| } | ||
| return "git_tag"; | ||
| }; | ||
|
|
||
| const resolveTarballFile = (baseDir: string, relativePath: string) => { | ||
| const sanitized = relativePath.trim(); | ||
| if (!sanitized) { | ||
| throw new Error("Tarball path is required"); | ||
| } | ||
| if (path.isAbsolute(sanitized)) { | ||
| throw new Error("Absolute tarball paths are not allowed"); | ||
| } | ||
|
|
||
| const resolvedBase = path.resolve(baseDir); | ||
| const resolvedFile = path.resolve(resolvedBase, sanitized); | ||
| const allowedPrefix = `${resolvedBase}${path.sep}`; | ||
| if (resolvedFile !== resolvedBase && !resolvedFile.startsWith(allowedPrefix)) { | ||
| throw new Error("Tarball path escapes configured base directory"); | ||
| } | ||
|
|
||
| return resolvedFile; | ||
| }; | ||
|
|
||
| export async function GET(_request: NextRequest, { params }: RouteParams) { | ||
| try { | ||
| const { name } = await params; | ||
| const decoded = decodeURIComponent(name ?? ""); | ||
| const payload = await gatewayRequest<{ job: AdminJob }>({ | ||
| method: "GET", | ||
| path: `/admin/v1/jobs/${encodeURIComponent(decoded)}`, | ||
| }); | ||
| const job = payload.job; | ||
| if (!job) { | ||
| return NextResponse.json({ error: "Job not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| const source = normalizeSource(job.source); | ||
| if (source === "tarball_url") { | ||
| const target = job.tarballUrl?.trim(); | ||
| if (!target) { | ||
| return NextResponse.json( | ||
| { error: "Tarball URL missing for this job" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| return NextResponse.redirect(target, 307); | ||
| } | ||
|
|
||
| if (source !== "tarball_path") { | ||
| return NextResponse.json( | ||
| { error: "This job source has no downloadable tarball" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const baseDir = | ||
| process.env.WORKER_ADMIN_JOB_TARBALL_DIR ?? | ||
| process.env.WORKER_JOB_TARBALL_DIR ?? | ||
| ""; | ||
| if (!baseDir) { | ||
| return NextResponse.json( | ||
| { | ||
| error: | ||
| "Tarball downloads are disabled. Set WORKER_ADMIN_JOB_TARBALL_DIR.", | ||
| }, | ||
| { status: 501 } | ||
| ); | ||
| } | ||
|
|
||
| const relativePath = job.tarballPath?.trim() ?? ""; | ||
| const tarballPath = resolveTarballFile(baseDir, relativePath); | ||
| const stat = await fsp.stat(tarballPath); | ||
| if (!stat.isFile()) { | ||
| return NextResponse.json({ error: "Tarball not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| const stream = createReadStream(tarballPath); | ||
| const webStream = Readable.toWeb(stream) as ReadableStream<Uint8Array>; | ||
| const filename = path.basename(tarballPath); | ||
| const response = new NextResponse(webStream, { | ||
| status: 200, | ||
| headers: { | ||
| "Content-Type": "application/gzip", | ||
| "Content-Length": stat.size.toString(), | ||
| "Content-Disposition": `attachment; filename="${filename}"`, | ||
| "Cache-Control": "no-store", | ||
| }, | ||
|
Comment on lines
+102
to
+110
|
||
| }); | ||
| const sha256 = job.tarballSha256?.trim(); | ||
| if (sha256) { | ||
| response.headers.set("X-Tarball-SHA256", sha256); | ||
| } | ||
|
|
||
| return response; | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { error: (error as Error).message ?? "artifact_download_failed" }, | ||
| { status: 502 } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useMemo, useState } from "react"; | ||
| import { useEffect, useState } from "react"; | ||
| import Link from "next/link"; | ||
| import { RelativeTime } from "@/components/relative-time"; | ||
| import type { JobEvent } from "@/lib/types"; | ||
|
|
@@ -91,14 +91,11 @@ export function JobRunDetail({ | |
| const finishedAt = event?.finishedAtMs ?? 0; | ||
| const durationMs = event?.durationMs ?? 0; | ||
|
|
||
| const metadata = useMemo(() => { | ||
| if (!event?.metadata) { | ||
| return []; | ||
| } | ||
| return Object.entries(event.metadata).sort(([a], [b]) => | ||
| a.localeCompare(b) | ||
| ); | ||
| }, [event?.metadata]); | ||
| const metadata = event?.metadata | ||
| ? Object.entries(event.metadata).sort(([left], [right]) => | ||
| left.localeCompare(right) | ||
| ) | ||
| : []; | ||
|
Comment on lines
+94
to
+98
|
||
|
|
||
| return ( | ||
| <section className="rounded-3xl border border-soft bg-white/90 p-6 shadow-soft"> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fsp.stat(tarballPath)will throw for missing files (ENOENT), which currently falls into the catch-all and returns a 502. That makes “tarball not found” look like a gateway failure. Handle ENOENT (and possibly ENOTDIR) explicitly and return 404 before the generic error handler.