Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ UI env vars:
- `WORKER_ADMIN_API_URL` (e.g. `https://127.0.0.1:8081`)
- `WORKER_ADMIN_MTLS_CERT`, `WORKER_ADMIN_MTLS_KEY`, `WORKER_ADMIN_MTLS_CA`
- `WORKER_ADMIN_PASSWORD` (required)
- `WORKER_ADMIN_JOB_TARBALL_DIR` (required for local tarball downloads)
- `WORKER_ADMIN_ALLOW_MOCK=false`
- `NEXT_PUBLIC_WORKER_ADMIN_ORIGIN` (optional override for SSR fetch)

Expand Down
124 changes: 124 additions & 0 deletions admin-ui/src/app/api/jobs/[name]/artifact/route.ts
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);
Copy link

Copilot AI Feb 9, 2026

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.

Suggested change
const stat = await fsp.stat(tarballPath);
let stat;
try {
stat = await fsp.stat(tarballPath);
} catch (error) {
const err = error as { code?: string };
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return NextResponse.json({ error: "Tarball not found" }, { status: 404 });
}
throw error;
}

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Disposition uses filename derived from user-configurable job.tarballPath. If the basename contains quotes or other special characters, the header can become invalid and break downloads. Consider sanitizing/stripping unsafe characters and/or using an RFC 5987 filename* parameter.

Copilot uses AI. Check for mistakes.
});
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 }
);
}
}
98 changes: 93 additions & 5 deletions admin-ui/src/components/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";

type ConfirmTone = "default" | "danger";

Expand Down Expand Up @@ -85,24 +85,112 @@ export function ConfirmDialog({
onConfirm: () => void;
onCancel: () => void;
}) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const titleID = useId();
const descriptionID = useId();

useEffect(() => {
if (!open) {
return undefined;
}

restoreFocusRef.current = document.activeElement as HTMLElement | null;
cancelButtonRef.current?.focus();

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();

return;
}

if (event.key !== "Tab") {
return;
}

const root = dialogRef.current;
if (!root) {
return;
}

const focusableElements = root.querySelectorAll<HTMLElement>(
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])',
);
if (focusableElements.length === 0) {
event.preventDefault();
root.focus();

return;
}

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;

if (event.shiftKey && activeElement === firstElement) {
event.preventDefault();
lastElement.focus();

return;
}

if (!event.shiftKey && activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};

document.addEventListener("keydown", onKeyDown);

return () => {
document.removeEventListener("keydown", onKeyDown);
restoreFocusRef.current?.focus();
};
}, [onCancel, open]);

if (!open) {
return null;
}

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="w-full max-w-lg rounded-3xl border border-soft bg-white p-6 shadow-soft">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onCancel();
}
}}
>
<div
ref={dialogRef}
role={tone === "danger" ? "alertdialog" : "dialog"}
aria-modal="true"
aria-labelledby={titleID}
aria-describedby={descriptionID}
tabIndex={-1}
className="w-full max-w-lg rounded-3xl border border-soft bg-white p-6 shadow-soft"
>
<p className="text-xs uppercase tracking-[0.2em] text-muted">Confirm</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm text-slate-600">{message}</p>
<h2 id={titleID} className="mt-2 text-lg font-semibold text-slate-900">
{title}
</h2>
<p id={descriptionID} className="mt-2 text-sm text-slate-600">
{message}
</p>
<div className="mt-6 flex flex-wrap justify-end gap-2">
<button
ref={cancelButtonRef}
type="button"
onClick={onCancel}
className="rounded-full border border-soft px-4 py-2 text-xs font-semibold text-muted"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
className={`rounded-full px-4 py-2 text-xs font-semibold text-white ${
tone === "danger"
Expand Down
15 changes: 13 additions & 2 deletions admin-ui/src/components/job-events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const mergeEventList = (primary: JobEvent[], secondary: JobEvent[]) => {

export function JobEvents({ events }: { events: JobEvent[] }) {
const [items, setItems] = useState<JobEvent[]>(events);
const [nowMs, setNowMs] = useState(0);
const [statusFilter, setStatusFilter] = useState("all");
const [sourceFilter, setSourceFilter] = useState("all");
const [queueFilter, setQueueFilter] = useState("all");
Expand All @@ -125,6 +126,16 @@ export function JobEvents({ events }: { events: JobEvent[] }) {
setItems(events);
}, [events]);

useEffect(() => {
const bootstrapId = window.setTimeout(() => setNowMs(Date.now()), 0);
const tickId = window.setInterval(() => setNowMs(Date.now()), refreshIntervalMs);

return () => {
clearTimeout(bootstrapId);
clearInterval(tickId);
};
}, []);

useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null;
let source: EventSource | null = null;
Expand Down Expand Up @@ -254,7 +265,7 @@ export function JobEvents({ events }: { events: JobEvent[] }) {
list = list.filter((event) => queueKey(event) === queueFilter);
}

const now = Date.now();
const now = nowMs > 0 ? nowMs : 0;
let windowMs = 0;
if (rangeFilter === "24h") {
windowMs = 24 * 60 * 60 * 1000;
Expand All @@ -269,7 +280,7 @@ export function JobEvents({ events }: { events: JobEvent[] }) {
}

return list;
}, [filterName, items, queueFilter, rangeFilter, sourceFilter, statusFilter]);
}, [filterName, items, nowMs, queueFilter, rangeFilter, sourceFilter, statusFilter]);

const visibleItems = useMemo(() => {
const start = (page - 1) * pageSize;
Expand Down
17 changes: 14 additions & 3 deletions admin-ui/src/components/job-overview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { JobEvent } from "@/lib/types";

const windows = [
Expand All @@ -19,11 +19,22 @@ const eventTimestamp = (event: JobEvent) =>

export function JobOverview({ events }: { events: JobEvent[] }) {
const [windowValue, setWindowValue] = useState("week");
const [nowMs, setNowMs] = useState(0);

useEffect(() => {
const bootstrapId = window.setTimeout(() => setNowMs(Date.now()), 0);
const tickId = window.setInterval(() => setNowMs(Date.now()), 60_000);

return () => {
clearTimeout(bootstrapId);
clearInterval(tickId);
};
}, []);

const summary = useMemo(() => {
const selected =
windows.find((entry) => entry.value === windowValue) ?? windows[1];
const cutoff = Date.now() - selected.ms;
const cutoff = (nowMs > 0 ? nowMs : 0) - selected.ms;

let running = 0;
let success = 0;
Expand Down Expand Up @@ -52,7 +63,7 @@ export function JobOverview({ events }: { events: JobEvent[] }) {
}

return { running, success, failed, queued };
}, [events, windowValue]);
}, [events, nowMs, windowValue]);

return (
<div className="rounded-3xl border border-soft bg-white/90 p-6 shadow-soft">
Expand Down
15 changes: 6 additions & 9 deletions admin-ui/src/components/job-run-detail.tsx
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";
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata is now sorted on every render. Since this component can re-render whenever SSE/fetch updates land, consider restoring useMemo (keyed on event?.metadata) to avoid repeated sorting and allocations for large metadata payloads.

Copilot uses AI. Check for mistakes.

return (
<section className="rounded-3xl border border-soft bg-white/90 p-6 shadow-soft">
Expand Down
Loading
Loading