import { useEffect, useState, useMemo, Fragment } from "react"; import { Cpu, Terminal, XCircle, AlertCircle, Plus, ChevronDown } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SearchInput } from "@/components/SearchInput"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfirmFooter } from "@/components/ConfirmFooter"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { LogConsole } from "@/components/LogConsole"; import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/PageHeader"; import { MessageLine } from "@/components/MessageLine"; import { Loading, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { api, buildLogsStreamURL, humanizeError } from "@/lib/api"; import { formatRelative, formatAbsolute } from "@/lib/format"; import { useConfig } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import type { Build, BuildStatus, Submission } from "@/lib/types"; const LOCAL_STORAGE_KEY = "felis_triggered_builds"; const PAGE_SIZE = 10; const STATUS_BADGE_STYLE: Record = { pending: "bg-amber-500/10 text-amber-500 border-amber-500/20 animate-pulse", building: "bg-blue-500/10 text-blue-500 border-blue-500/20 animate-pulse", succeeded: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", failed: "bg-rose-500/10 text-rose-500 border-rose-500/20", cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", }; function formatDuration( createdAt: string, finishedAt: string | undefined, t: (key: string, opts?: Record) => string ): string { const start = new Date(createdAt).getTime(); if (!Number.isFinite(start)) return ""; const end = finishedAt ? new Date(finishedAt).getTime() : Date.now(); if (!Number.isFinite(end) || end < start) return ""; const diffSec = Math.round((end - start) / 1000); if (diffSec < 60) { return t("build_duration_seconds", { s: diffSec }); } return t("build_duration_minutes", { m: Math.floor(diffSec / 60), s: diffSec % 60 }); } export function ImageBuildPage() { const { t, i18n } = useTranslation("admin"); const locale = i18n.language; const now = Date.now(); const config = useConfig(); // The owner tier comes from the server-side /me verdict (TierProvider), never // from guessing at the address: the old mock-era email heuristic misread the // real owner (felis-owner@example.com) as non-owner while treating any // "owner@…" address as one. const { isOwner } = useTier(); // Form & Dialog State const [dialogOpen, setDialogOpen] = useState(false); const [imageRef, setImageRef] = useState(""); const [contextRef, setContextRef] = useState(""); const [dockerfile, setDockerfile] = useState(""); const [baseImage, setBaseImage] = useState(""); const [triggering, setTriggering] = useState(false); const [triggerError, setTriggerError] = useState(null); const [submissions, setSubmissions] = useState([]); const [loadingSubmissions, setLoadingSubmissions] = useState(false); const [selectedSub, setSelectedSub] = useState(null); const visibleSubmissions = useMemo(() => { if (isOwner) { return submissions; } return submissions.filter((s) => s.status === "approved"); }, [submissions, isOwner]); useEffect(() => { if (dialogOpen) { setLoadingSubmissions(true); api.listSubmissions() .then(setSubmissions) .catch(() => {}) .finally(() => { setLoadingSubmissions(false); }); } }, [dialogOpen]); const handleSelectSubmission = (subId: string) => { if (!subId || subId.startsWith("_")) return; const sub = submissions.find((s) => s.id === subId); if (!sub) return; setSelectedSub(sub); const derivedImageRef = sub.image_ref || `registry.felis.svc:5000/user-uploads/${sub.id}:latest`; setImageRef(derivedImageRef); setContextRef(sub.context_ref); setDockerfile( `# felis user-modpack submission ${sub.id}\n` + `# The executed Dockerfile is provided by the uploaded build context:\n` + `# ${sub.context_ref}\n` + `# Built in the isolated felis-build sandbox and Trivy-gated (spec §16).\n` ); setBaseImage(""); }; // Build List State const [buildIds, setBuildIds] = useState([]); const [builds, setBuilds] = useState([]); const [loadingBuilds, setLoadingBuilds] = useState(true); const [activeLogBuildId, setActiveLogBuildId] = useState(null); const [cancelBuild, setCancelBuild] = useState(null); // Search & Pagination State const [search, setSearch] = useState(""); const [page, setPage] = useState(1); // Load build IDs from localStorage on mount useEffect(() => { try { const stored = localStorage.getItem(LOCAL_STORAGE_KEY); if (stored) { setBuildIds(JSON.parse(stored)); } } catch { setLoadingBuilds(false); } }, []); // Fetch full details of builds const fetchBuilds = async (ids: string[]) => { if (ids.length === 0) { setBuilds([]); setLoadingBuilds(false); return; } try { const list = await Promise.all( ids.map(async (id) => { try { return await api.getBuild(id); } catch { return null; // ignore individual failures } }) ); // Filter out nulls and sort by creation time (newest first) const validBuilds = list.filter((b): b is Build => b !== null); validBuilds.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); setBuilds(validBuilds); } catch { // ignore } finally { setLoadingBuilds(false); } }; // Fetch builds when IDs change useEffect(() => { fetchBuilds(buildIds); }, [buildIds]); // Poll active builds useEffect(() => { const active = builds.some((b) => b.status === "pending" || b.status === "building"); if (!active) return; const timer = setInterval(() => { fetchBuilds(buildIds); }, 4000); return () => clearInterval(timer); }, [builds, buildIds]); // Filtered & Paginated Builds const filteredBuilds = useMemo(() => { let list = [...builds]; if (search.trim()) { const q = search.toLowerCase(); list = list.filter( (b) => b.id.toLowerCase().includes(q) || b.image_ref.toLowerCase().includes(q) || b.status.toLowerCase().includes(q) ); } return list; }, [builds, search]); // Reset page when search changes useEffect(() => { setPage(1); }, [search]); const paginatedBuilds = useMemo(() => { const start = (page - 1) * PAGE_SIZE; return filteredBuilds.slice(start, start + PAGE_SIZE); }, [filteredBuilds, page]); // Form submission handler const handleTrigger = async (e?: React.FormEvent) => { e?.preventDefault(); if (!imageRef.trim() || !contextRef.trim() || !dockerfile.trim()) return; setTriggering(true); setTriggerError(null); try { const newBuild = await api.buildImage({ image_ref: imageRef.trim(), context_ref: contextRef.trim(), dockerfile: dockerfile, base_image: baseImage.trim() || undefined, }); // Save build ID to localStorage and update state const nextIds = [newBuild.id, ...buildIds]; localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextIds)); setBuildIds(nextIds); setActiveLogBuildId(newBuild.id); setDialogOpen(false); // Clear form setImageRef(""); setContextRef(""); setDockerfile(""); setBaseImage(""); } catch (err) { setTriggerError(humanizeError(err)); } finally { setTriggering(false); } }; // Cancel build handler const handleCancel = async (id: string) => { await api.cancelBuild(id); fetchBuilds(buildIds); }; if (!config) { return ; } return (
{ setDialogOpen(o); if (!o) { setTriggerError(null); setSelectedSub(null); } }}> {t("trigger_build_title")} {t("trigger_build_desc")}

{t("build_import_submission_hint")}

{selectedSub && selectedSub.status !== "approved" && (

{t("build_import_submission_warning_title", { status: selectedSub.status === "pending_review" ? t("status_pending_review") : t("status_rejected") })}

{t("build_import_submission_warning_desc")}

)}
setImageRef(e.target.value)} disabled={triggering} required />
setContextRef(e.target.value)} disabled={triggering} required />
setBaseImage(e.target.value)} disabled={triggering} />