import { useState, useMemo } from "react"; import { Link } from "react-router-dom"; import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react"; import { SearchInput } from "@/components/SearchInput"; import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; 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 { cn } from "@/lib/utils"; import { MessageLine } from "@/components/MessageLine"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; const PAGE_SIZE = 10; // image_whitelist.source is plain text (build.Source* in Go); the values Felis // writes itself get a name in the UI language, anything else shows as stored. const SOURCE_KEYS: Record = { built: "image_source_built", external: "image_source_external", recommended: "image_source_recommended", }; function sourceLabel(source: string, t: TFunction): string { const key = SOURCE_KEYS[source]; return key ? t(key) : source; } export function ImageAdmin() { const { t } = useTranslation("admin"); const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const images = useMemo(() => data ?? [], [data]); // Form & Dialog State const [dialogOpen, setDialogOpen] = useState(false); const [newImageRef, setNewImageRef] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [removeRef, setRemoveRef] = useState(null); // Search & Filtering State const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); const [page, setPage] = useState(1); // Stats const stats = useMemo(() => { const total = images.length; const enabled = images.filter((img) => img.enabled).length; const disabled = total - enabled; return { total, enabled, disabled }; }, [images]); const { total, enabled, disabled } = stats; // The pipeline card counts builds, which live in their own history: one row is // enough, the answer carries the total. A read that failed shows a dash. const buildsQ = useAsync(() => api.listBuilds({ limit: 1 }), []); // Filtered & Paginated Images const filteredImages = useMemo(() => { let list = [...images]; // 1. Search Filter if (search.trim()) { const q = search.toLowerCase(); list = list.filter( (img) => img.image_ref.toLowerCase().includes(q) || (img.source && (img.source.toLowerCase().includes(q) || sourceLabel(img.source, t).toLowerCase().includes(q))) ); } // 2. Status Filter if (statusFilter === "enabled") { list = list.filter((img) => img.enabled); } else if (statusFilter === "disabled") { list = list.filter((img) => !img.enabled); } return list; }, [images, search, statusFilter, t]); // Reset page when filter changes const lastFilterKey = `${search}-${statusFilter}`; const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey); if (prevFilterKey !== lastFilterKey) { setPage(1); setPrevFilterKey(lastFilterKey); } const paginatedImages = useMemo(() => { const start = (page - 1) * PAGE_SIZE; return filteredImages.slice(start, start + PAGE_SIZE); }, [filteredImages, page]); async function handleAdd(e?: React.FormEvent) { e?.preventDefault(); if (!newImageRef.trim()) return; setSubmitting(true); setSubmitError(null); try { await api.addImage(newImageRef.trim()); setNewImageRef(""); setDialogOpen(false); reload(); } catch (err) { setSubmitError(humanizeError(err)); } finally { setSubmitting(false); } } async function handleRemove(imageRef: string) { await api.removeImage(imageRef); reload(); } return (
{ setDialogOpen(o); if (!o) { setNewImageRef(""); setSubmitError(null); } }}> {t("add_image_title")} {t("add_image_desc")}
setNewImageRef(e.target.value)} disabled={submitting} required />
{submitError && } setDialogOpen(false)} onConfirm={() => handleAdd()} disabled={submitting || !newImageRef.trim()} loading={submitting} cancelLabel={t("common:cancel")} confirmLabel={t("add_image_btn")} />
} className="mb-6" /> {/* Stats Cards Row */}
{/* Whitelist Table Card */} {/* Filters Bar */}
{/* List Content */} {loading && !data ? (
) : error ? (
) : filteredImages.length === 0 ? (
) : (
{/* Table Header */}
{t("table_ref")}
{t("table_source")}
{t("table_status")}
{t("table_action")}
{/* Table Body */}
{paginatedImages.map((img) => (
{/* Reference */}
{img.image_ref}
{/* Source */}
{img.source ? ( {sourceLabel(img.source, t)} ) : ( — )}
{/* Status */}
{img.enabled ? ( {t("enabled")} ) : ( {t("disabled")} )}
{/* Action */}
))}
{/* Pagination */} {filteredImages.length > PAGE_SIZE && (
)}
)}
!o && setRemoveRef(null)} title={t("delete_image_title")} description={ <> {t("delete_image_desc")}{" "} {removeRef} } confirmLabel={t("delete_image_confirm")} onConfirm={() => handleRemove(removeRef!)} />
); }