import React, { useState, useEffect, useRef } from "react"; import { toast } from "react-toastify"; import { DataTable } from "primereact/datatable"; import { Column } from "primereact/column"; import { Dropdown } from "primereact/dropdown"; import { Button } from "@mui/joy"; import { Dialog } from "primereact/dialog"; import { authFetch } from "@/utils/authFetch"; import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog"; import BreadcrumbNav from "./BreadcrumbNav"; import { API_URL } from "@/config"; import "./RequestManagement.css"; interface RequestJob { id: string | number; target: string; tracks: number; quality: string; status: string; progress: number | string | null; type?: string; tarball?: string; created_at?: string; updated_at?: string; [key: string]: unknown; } const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"]; const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix export default function RequestManagement() { const [requests, setRequests] = useState([]); const [filterType, setFilterType] = useState(null); const [filterStatus, setFilterStatus] = useState(null); const [filteredRequests, setFilteredRequests] = useState([]); const [selectedRequest, setSelectedRequest] = useState(null); const [isDialogVisible, setIsDialogVisible] = useState(false); const [isLoading, setIsLoading] = useState(true); const pollingRef = useRef | null>(null); const pollingDetailRef = useRef | null>(null); const resolveTarballPath = (job: RequestJob) => job.tarball; const tarballUrl = (absPath: string | undefined, quality: string) => { if (!absPath) return null; const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz" // If the backend already stores a fully qualified URL, return as-is if (/^https?:\/\//i.test(absPath)) return absPath; return `${TAR_BASE_URL}/${quality}/${filename}`; }; const fetchJobs = async (showLoading = true) => { try { if (showLoading) setIsLoading(true); const res = await authFetch(`${API_URL}/trip/jobs/list`); if (!res.ok) throw new Error("Failed to fetch jobs"); const data = await res.json() as { jobs?: RequestJob[] }; setRequests(Array.isArray(data.jobs) ? data.jobs : []); } catch (err) { console.error(err); if (!toast.isActive('fetch-fail-toast')) { toast.error("Failed to fetch jobs list", { toastId: 'fetch-fail-toast', }); } } finally { setIsLoading(false); } }; const fetchJobDetail = async (jobId: string | number): Promise => { try { const res = await authFetch(`${API_URL}/trip/job/${jobId}`); if (!res.ok) throw new Error("Failed to fetch job details"); return await res.json() as RequestJob; } catch (err) { console.error(err); if (!toast.isActive('fetch-job-fail-toast')) { toast.error("Failed to fetch job details", { toastId: "fetch-job-fail-toast", }); } return null; } }; // Initial load shows the skeleton; subsequent polling should not useEffect(() => { fetchJobs(true); }, []); useEffect(() => { if (isDialogVisible && selectedRequest) { // Start polling const poll = async () => { const updated = await fetchJobDetail(selectedRequest.id); if (updated) setSelectedRequest(updated); }; pollingDetailRef.current = setInterval(poll, 1500); return () => { if (pollingDetailRef.current) { clearInterval(pollingDetailRef.current); pollingDetailRef.current = null; } }; } }, [isDialogVisible, selectedRequest?.id]); useEffect(() => { const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status)); if (hasActive && !pollingRef.current) pollingRef.current = setInterval(() => fetchJobs(false), 1500); else if (!hasActive && pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } return () => { if (pollingRef.current) clearInterval(pollingRef.current); pollingRef.current = null; }; }, [requests]); useEffect(() => { const filtered = requests.filter((r) => { const typeMatch = !filterType || r.type === filterType; const statusMatch = filterStatus === "all" || filterStatus === null || r.status === filterStatus; return typeMatch && statusMatch; }); setFilteredRequests(filtered); }, [filterType, filterStatus, requests]); const getStatusColorClass = (status: string) => { switch (status) { case "Queued": return "bg-yellow-700 text-white"; case "Started": return "bg-blue-700 text-white"; case "Compressing": return "bg-orange-700 text-white"; case "Finished": return "bg-green-700 text-white"; case "Failed": return "bg-red-700 text-white"; default: return "bg-gray-700 text-white"; } }; const getQualityColorClass = (quality: string) => { switch (quality) { case "FLAC": return "bg-green-700 text-white"; case "Lossy": return "bg-yellow-700 text-white"; default: return "bg-gray-700 text-white"; } }; const statusBodyTemplate = (rowData: RequestJob) => ( {rowData.status} ); const qualityBodyTemplate = (rowData: RequestJob) => ( {rowData.quality} ); const safeText = (val: unknown) => (val === 0 ? "0" : val || "—"); const textWithEllipsis = (val: string | undefined | null, width = "12rem") => ( {val || "—"} ); const truncate = (text: string, maxLen: number) => maxLen <= 3 ? text.slice(0, maxLen) : text.length <= maxLen ? text : text.slice(0, maxLen - 3) + '...'; const basename = (p: string | undefined) => (typeof p === "string" ? p.split("/").pop() : ""); const formatProgress = (p: unknown) => { if (p === null || p === undefined || p === "") return "—"; const pct = computePct(p); return `${pct}%`; }; const computePct = (p: unknown) => { if (p === null || p === undefined || p === "") return 0; const num = Number(p); if (!Number.isFinite(num)) return 0; const normalized = num > 1 ? num : num * 100; return Math.min(100, Math.max(0, Math.round(normalized))); }; const progressBarTemplate = (rowData: RequestJob) => { const p = rowData.progress; if (p === null || p === undefined || p === "") return "—"; const pct = computePct(p); const getProgressColor = () => { if (rowData.status === "Failed") return "bg-red-500"; if (rowData.status === "Finished") return "bg-green-500"; if (pct < 30) return "bg-blue-400"; if (pct < 70) return "bg-blue-500"; return "bg-blue-600"; }; return (
{pct}%
); }; const confirmDelete = (requestId: string | number) => { confirmDialog({ message: "Are you sure you want to delete this request?", header: "Confirm Delete", icon: "pi pi-exclamation-triangle", accept: () => deleteRequest(requestId), }); }; const deleteRequest = (requestId: string | number) => { setRequests((prev) => prev.filter((r) => r.id !== requestId)); toast.success("Request deleted"); }; const actionBodyTemplate = (rowData: RequestJob) => ( ); const handleRowClick = async (e: { data: unknown }) => { const rowData = e.data as RequestJob; const detail = await fetchJobDetail(rowData.id); if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); } }; return (

Manage Requests

({ label: s, value: s }))]} onChange={(e) => setFilterStatus(e.value)} placeholder="Filter by Status" className="min-w-[180px]" />
{isLoading ? (
{[...Array(5)].map((_, i) => (
))}
) : (

No requests found

Requests you submit will appear here

} onRowClick={handleRowClick} resizableColumns={false} className="w-full" style={{ width: '100%' }} > ( {String(row.id).split("-").slice(-1)[0]} )} /> textWithEllipsis(row.target, "100%")} /> row.tracks} /> Tarball } body={(row: RequestJob) => { const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC"); if (!url) return "—"; const encodedURL = encodeURI(url); const fileName = url.split("/").pop() || ""; return ( {truncate(fileName, 28)} ); }} />
)} setIsDialogVisible(false)} breakpoints={{ "960px": "95vw" }} modal dismissableMask className="dark:bg-neutral-900 dark:text-neutral-100" > {selectedRequest ? (
{/* --- Metadata Card --- */}
{selectedRequest.id &&

ID: {String(selectedRequest.id)}

} {selectedRequest.target &&

Target: {selectedRequest.target}

} {selectedRequest.tracks &&

# Tracks: {selectedRequest.tracks}

} {selectedRequest.quality && (

Quality:{" "} {selectedRequest.quality}

)}
{/* --- Status / Progress Card --- */}
{selectedRequest.status && (

Status:{" "} {selectedRequest.status}

)} {selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
Progress:
{(() => { const pctDialog = computePct(selectedRequest.progress); const status = selectedRequest.status; const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : "bg-blue-500"; return (
= 100 ? '999px' : 0, borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0 }} data-pct={pctDialog} aria-valuenow={pctDialog} aria-valuemin={0} aria-valuemax={100} /> ); })()}
{formatProgress(selectedRequest.progress)}
)}
{/* --- Timestamps Card --- */}
{selectedRequest.created_at &&

Enqueued: {new Date(selectedRequest.created_at).toLocaleString()}

} {(selectedRequest as RequestJob & { started_at?: string }).started_at &&

Started: {new Date((selectedRequest as RequestJob & { started_at: string }).started_at).toLocaleString()}

} {(selectedRequest as RequestJob & { ended_at?: string }).ended_at &&

Ended: {new Date((selectedRequest as RequestJob & { ended_at: string }).ended_at).toLocaleString()}

}
{/* --- Tarball Card --- */} { selectedRequest.tarball && ( ) }
) : (

Loading...

) }
); }