Files
codey.lol/src/components/TRip/RequestManagement.tsx

476 lines
18 KiB
TypeScript
Raw Normal View History

2025-08-20 07:32:40 -04:00
import React, { useState, useEffect, useRef } from "react";
import { toast } from "react-toastify";
2025-07-31 19:28:59 -04:00
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";
2025-07-31 20:25:02 -04:00
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
2025-07-31 19:28:59 -04:00
import BreadcrumbNav from "./BreadcrumbNav";
2025-08-20 07:32:40 -04:00
import { API_URL } from "@/config";
import "./RequestManagement.css";
2025-07-31 19:28:59 -04:00
2025-12-19 11:59:00 -05:00
interface RequestJob {
id: string | number;
target: string;
tracks: number;
quality: string;
status: string;
progress: number | string | null;
2025-12-19 11:59:00 -05:00
type?: string;
tarball?: string;
2025-12-19 11:59:00 -05:00
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
2025-08-28 11:15:17 -04:00
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
2025-07-31 19:28:59 -04:00
export default function RequestManagement() {
2025-12-19 11:59:00 -05:00
const [requests, setRequests] = useState<RequestJob[]>([]);
const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string | null>(null);
const [filteredRequests, setFilteredRequests] = useState<RequestJob[]>([]);
const [selectedRequest, setSelectedRequest] = useState<RequestJob | null>(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
2025-12-19 11:59:00 -05:00
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
2025-08-20 07:32:40 -04:00
const resolveTarballPath = (job: RequestJob) => job.tarball;
2025-12-19 11:59:00 -05:00
const tarballUrl = (absPath: string | undefined, quality: string) => {
2025-08-20 07:32:40 -04:00
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}`;
2025-08-20 07:32:40 -04:00
};
const fetchJobs = async (showLoading = true) => {
2025-08-20 07:32:40 -04:00
try {
if (showLoading) setIsLoading(true);
2025-08-20 07:32:40 -04:00
const res = await authFetch(`${API_URL}/trip/jobs/list`);
if (!res.ok) throw new Error("Failed to fetch jobs");
2025-12-19 11:59:00 -05:00
const data = await res.json() as { jobs?: RequestJob[] };
2025-08-20 07:32:40 -04:00
setRequests(Array.isArray(data.jobs) ? data.jobs : []);
} catch (err) {
console.error(err);
2025-08-20 15:57:59 -04:00
if (!toast.isActive('fetch-fail-toast')) {
toast.error("Failed to fetch jobs list", {
toastId: 'fetch-fail-toast',
});
}
} finally {
setIsLoading(false);
2025-08-20 07:32:40 -04:00
}
};
2025-12-19 11:59:00 -05:00
const fetchJobDetail = async (jobId: string | number): Promise<RequestJob | null> => {
2025-08-20 07:32:40 -04:00
try {
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
if (!res.ok) throw new Error("Failed to fetch job details");
2025-12-19 11:59:00 -05:00
return await res.json() as RequestJob;
2025-08-20 07:32:40 -04:00
} catch (err) {
console.error(err);
if (!toast.isActive('fetch-job-fail-toast')) {
toast.error("Failed to fetch job details",
{
toastId: "fetch-job-fail-toast",
});
}
2025-08-20 07:32:40 -04:00
return null;
}
};
2025-07-31 19:28:59 -04:00
2025-12-18 11:19:01 -05:00
// Initial load shows the skeleton; subsequent polling should not
useEffect(() => { fetchJobs(true); }, []);
2025-07-31 19:28:59 -04:00
useEffect(() => {
2025-08-20 07:32:40 -04:00
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;
}
};
2025-07-31 19:28:59 -04:00
}
2025-08-20 07:32:40 -04:00
}, [isDialogVisible, selectedRequest?.id]);
useEffect(() => {
2025-08-28 11:15:17 -04:00
const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status));
2025-12-18 11:19:01 -05:00
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(() => fetchJobs(false), 1500);
2025-08-20 07:32:40 -04:00
else if (!hasActive && pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
2025-07-31 19:28:59 -04:00
}
2025-08-20 07:32:40 -04:00
return () => { if (pollingRef.current) clearInterval(pollingRef.current); pollingRef.current = null; };
}, [requests]);
useEffect(() => {
2025-08-20 15:57:59 -04:00
const filtered = requests.filter((r) => {
const typeMatch = !filterType || r.type === filterType;
const statusMatch = filterStatus === "all" || filterStatus === null || r.status === filterStatus;
return typeMatch && statusMatch;
});
2025-07-31 19:28:59 -04:00
setFilteredRequests(filtered);
}, [filterType, filterStatus, requests]);
2025-08-20 15:57:59 -04:00
2025-12-19 11:59:00 -05:00
const getStatusColorClass = (status: string) => {
switch (status) {
2025-08-28 11:15:17 -04:00
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";
}
};
2025-12-19 11:59:00 -05:00
const getQualityColorClass = (quality: string) => {
switch (quality) {
2025-08-28 11:15:17 -04:00
case "FLAC": return "bg-green-700 text-white";
case "Lossy": return "bg-yellow-700 text-white";
default: return "bg-gray-700 text-white";
}
};
2025-12-19 11:59:00 -05:00
const statusBodyTemplate = (rowData: RequestJob) => (
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
2025-08-20 07:32:40 -04:00
{rowData.status}
</span>
);
2025-12-19 11:59:00 -05:00
const qualityBodyTemplate = (rowData: RequestJob) => (
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
{rowData.quality}
</span>
);
2025-12-19 11:59:00 -05:00
const safeText = (val: unknown) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val: string | undefined | null, width = "12rem") => (
2025-08-20 07:32:40 -04:00
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
);
2025-12-19 11:59:00 -05:00
const truncate = (text: string, maxLen: number) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
? text
: text.slice(0, maxLen - 3) + '...';
2025-12-19 11:59:00 -05:00
const basename = (p: string | undefined) => (typeof p === "string" ? p.split("/").pop() : "");
2025-08-20 07:32:40 -04:00
2025-12-19 11:59:00 -05:00
const formatProgress = (p: unknown) => {
2025-08-20 07:32:40 -04:00
if (p === null || p === undefined || p === "") return "—";
const pct = computePct(p);
2025-08-20 07:32:40 -04:00
return `${pct}%`;
};
2025-12-19 11:59:00 -05:00
const computePct = (p: unknown) => {
2025-12-17 13:33:31 -05:00
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)));
2025-12-17 13:33:31 -05:00
};
2025-12-19 11:59:00 -05:00
const progressBarTemplate = (rowData: RequestJob) => {
const p = rowData.progress;
if (p === null || p === undefined || p === "") return "—";
2025-12-17 13:33:31 -05:00
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 (
2025-12-17 13:33:31 -05:00
<div className="rm-progress-container">
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
<div
2025-12-17 13:33:31 -05:00
className={`rm-progress-fill ${getProgressColor()}`}
style={{
2025-12-19 11:59:00 -05:00
// CSS custom property for progress animation
['--rm-progress' as string]: (pct / 100).toString(),
2025-12-17 13:33:31 -05:00
borderTopRightRadius: pct === 100 ? '999px' : 0,
borderBottomRightRadius: pct === 100 ? '999px' : 0
}}
data-pct={pct}
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
2025-12-17 13:33:31 -05:00
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%</span>
</div>
);
};
2025-12-19 11:59:00 -05:00
const confirmDelete = (requestId: string | number) => {
2025-07-31 19:28:59 -04:00
confirmDialog({
message: "Are you sure you want to delete this request?",
header: "Confirm Delete",
icon: "pi pi-exclamation-triangle",
accept: () => deleteRequest(requestId),
});
};
2025-12-19 11:59:00 -05:00
const deleteRequest = (requestId: string | number) => {
2025-07-31 19:28:59 -04:00
setRequests((prev) => prev.filter((r) => r.id !== requestId));
toast.success("Request deleted");
};
2025-12-19 11:59:00 -05:00
const actionBodyTemplate = (rowData: RequestJob) => (
2025-08-20 07:32:40 -04:00
<Button
color="neutral"
variant="outlined"
size="sm"
sx={{
color: "#e5e7eb",
borderColor: "#6b7280",
'&:hover': { backgroundColor: '#374151', borderColor: '#9ca3af' },
}}
onClick={(e) => { e.stopPropagation(); confirmDelete(rowData.id); }}
>
Delete
</Button>
);
2025-07-31 19:28:59 -04:00
2025-12-19 11:59:00 -05:00
const handleRowClick = async (e: { data: unknown }) => {
const rowData = e.data as RequestJob;
const detail = await fetchJobDetail(rowData.id);
2025-08-20 07:32:40 -04:00
if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
2025-07-31 19:28:59 -04:00
};
return (
2025-08-20 07:32:40 -04:00
<div className="trip-management-container my-10 p-4 sm:p-6 rounded-xl shadow-md
2025-08-20 07:32:40 -04:00
bg-white dark:bg-neutral-900
text-neutral-900 dark:text-neutral-100
border border-neutral-200 dark:border-neutral-700">
2025-07-31 19:28:59 -04:00
<BreadcrumbNav currentPage="management" />
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
2025-07-31 19:28:59 -04:00
<div className="flex flex-wrap items-center gap-4 mb-6">
2025-07-31 19:28:59 -04:00
<Dropdown
value={filterStatus}
2025-08-20 15:57:59 -04:00
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
2025-07-31 19:28:59 -04:00
onChange={(e) => setFilterStatus(e.value)}
placeholder="Filter by Status"
className="min-w-[180px]"
/>
</div>
{isLoading ? (
<div className="table-skeleton">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[22%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[12%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[16%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[20%]"><div className="skeleton-bar" /></div>
</div>
))}
</div>
) : (
<div className="table-wrapper w-full">
<DataTable
value={filteredRequests}
paginator
rows={10}
removableSort
sortMode="multiple"
emptyMessage={
<div className="empty-state">
<i className="pi pi-inbox empty-state-icon" />
<p className="empty-state-text">No requests found</p>
<p className="empty-state-subtext">Requests you submit will appear here</p>
</div>
2025-09-12 22:39:35 -04:00
}
onRowClick={handleRowClick}
resizableColumns={false}
className="w-full"
style={{ width: '100%' }}
>
<Column
field="id"
header="ID"
2025-12-19 11:59:00 -05:00
body={(row: RequestJob) => (
<span title={String(row.id)}>
{String(row.id).split("-").slice(-1)[0]}
</span>
)}
/>
2025-12-19 11:59:00 -05:00
<Column field="target" header="Target" sortable body={(row: RequestJob) => textWithEllipsis(row.target, "100%")} />
<Column field="tracks" header="# Tracks" body={(row: RequestJob) => row.tracks} />
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
<Column
field="quality"
header="Quality"
body={qualityBodyTemplate}
style={{ textAlign: "center" }}
sortable />
<Column
field="tarball"
header={
<span className="flex items-center">
<i className="pi pi-download mr-1" />
Tarball
</span>
}
2025-12-19 11:59:00 -05:00
body={(row: RequestJob) => {
const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC");
if (!url) return "—";
2025-12-19 11:59:00 -05:00
const encodedURL = encodeURI(url);
2025-12-19 11:59:00 -05:00
const fileName = url.split("/").pop() || "";
return (
<a
href={encodedURL}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-500 hover:underline"
title={fileName}
>
{truncate(fileName, 28)}
</a>
);
}}
/>
</DataTable>
</div>
)}
2025-08-20 07:32:40 -04:00
2025-07-31 20:25:02 -04:00
<ConfirmDialog />
2025-08-20 07:32:40 -04:00
<Dialog
header="Request Details"
visible={isDialogVisible}
style={{ width: "500px" }}
onHide={() => setIsDialogVisible(false)}
2025-08-20 07:32:40 -04:00
breakpoints={{ "960px": "95vw" }}
modal
dismissableMask
2025-08-20 07:32:40 -04:00
className="dark:bg-neutral-900 dark:text-neutral-100"
>
2025-08-20 07:32:40 -04:00
{selectedRequest ? (
<div className="space-y-4 text-sm">
2025-08-28 11:15:17 -04:00
{/* --- Metadata Card --- */}
2025-12-17 13:33:31 -05:00
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
2025-12-19 11:59:00 -05:00
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {String(selectedRequest.id)}</p>}
2025-08-28 11:15:17 -04:00
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
{selectedRequest.quality && (
<p>
<strong>Quality:</strong>{" "}
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getQualityColorClass(selectedRequest.quality)}`}>
{selectedRequest.quality}
</span>
</p>
)}
</div>
{/* --- Status / Progress Card --- */}
2025-12-17 13:33:31 -05:00
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
2025-08-28 11:15:17 -04:00
{selectedRequest.status && (
<p>
<strong>Status:</strong>{" "}
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}>
{selectedRequest.status}
</span>
</p>
)}
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<div className="col-span-2">
<strong>Progress:</strong>
2025-12-17 13:33:31 -05:00
<div className="rm-progress-container mt-2">
<div className="rm-progress-track rm-progress-track-lg">
{(() => {
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 (
<div
className={`rm-progress-fill ${fillColor}`}
style={{
['--rm-progress' as string]: (pctDialog / 100).toString(),
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
}}
data-pct={pctDialog}
aria-valuenow={pctDialog}
aria-valuemin={0}
aria-valuemax={100}
/>
);
})()}
</div>
2025-12-17 13:33:31 -05:00
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}</span>
</div>
</div>
2025-08-28 11:15:17 -04:00
)}
</div>
{/* --- Timestamps Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
2025-12-19 11:59:00 -05:00
{selectedRequest.created_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.created_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { started_at?: string }).started_at && <p><strong>Started:</strong> {new Date((selectedRequest as RequestJob & { started_at: string }).started_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { ended_at?: string }).ended_at && <p><strong>Ended:</strong> {new Date((selectedRequest as RequestJob & { ended_at: string }).ended_at).toLocaleString()}</p>}
2025-08-28 11:15:17 -04:00
</div>
{/* --- Tarball Card --- */}
{
selectedRequest.tarball && (
2025-08-28 11:15:17 -04:00
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
<p>
<strong>Tarball:</strong>{" "}
<a
href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality) || "")}
2025-08-28 11:15:17 -04:00
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality)?.split("/").pop()}
2025-08-28 11:15:17 -04:00
</a>
</p>
</div>
)
}
</div >
2025-08-20 07:32:40 -04:00
) : (
<p>Loading...</p>
)
}
</Dialog >
2025-08-28 11:15:17 -04:00
</div >
2025-07-31 19:28:59 -04:00
);
}