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

380 lines
13 KiB
React
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";
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";
2025-07-31 19:28:59 -04:00
2025-08-20 07:32:40 -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-08-20 07:32:40 -04:00
const [requests, setRequests] = useState([]);
2025-07-31 19:28:59 -04:00
const [filterType, setFilterType] = useState(null);
const [filterStatus, setFilterStatus] = useState(null);
2025-08-20 07:32:40 -04:00
const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
2025-08-20 07:32:40 -04:00
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
const authFetch = async (url, options = {}) => fetch(url, { ...options, credentials: "include" });
const tarballUrl = (absPath) => {
if (!absPath) return null;
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
return `${TAR_BASE_URL}${filename}`;
};
const fetchJobs = async () => {
try {
const res = await authFetch(`${API_URL}/trip/jobs/list`);
if (!res.ok) throw new Error("Failed to fetch jobs");
const data = await res.json();
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',
});
}
2025-08-20 07:32:40 -04:00
}
};
2025-08-20 07:32:40 -04:00
const fetchJobDetail = async (jobId) => {
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();
} catch (err) {
console.error(err);
toast.error("Failed to fetch job details");
return null;
}
};
2025-07-31 19:28:59 -04:00
2025-08-20 07:32:40 -04:00
useEffect(() => { fetchJobs(); }, []);
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(() => {
const hasActive = requests.some((j) => ["queued", "started", "compressing"].includes(j.status));
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500);
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-08-20 07:32:40 -04:00
const getStatusColorClass = (status) => {
switch (status) {
2025-08-20 07:32:40 -04:00
case "queued": return "bg-yellow-500 text-black";
2025-08-20 15:57:59 -04:00
case "started": return "bg-blue-500 text-white";
case "compressing": return "bg-orange-500 text-white";
2025-08-20 07:32:40 -04:00
case "finished": return "bg-green-500 text-white";
case "failed": return "bg-red-500 text-white";
default: return "bg-gray-500 text-white";
}
};
2025-08-20 07:32:40 -04:00
const statusBodyTemplate = (rowData) => (
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
{rowData.status}
</span>
);
const safeText = (val) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val, width = "12rem") => (
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
);
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
const formatProgress = (p) => {
if (p === null || p === undefined || p === "") return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
2025-08-20 15:57:59 -04:00
const pct = num > 1 ? Math.round(num) : num;
2025-08-20 07:32:40 -04:00
return `${pct}%`;
};
2025-07-31 19:28:59 -04:00
const confirmDelete = (requestId) => {
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) => {
setRequests((prev) => prev.filter((r) => r.id !== requestId));
toast.success("Request deleted");
};
2025-08-20 07:32:40 -04:00
const actionBodyTemplate = (rowData) => (
<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-08-20 07:32:40 -04:00
const handleRowClick = async (e) => {
const detail = await fetchJobDetail(e.data.id);
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="w-max my-10 p-6 rounded-xl shadow-md
bg-white dark:bg-neutral-900
text-neutral-900 dark:text-neutral-100
border border-neutral-200 dark:border-neutral-700
sm:p-4 md:p-6">
2025-07-31 19:28:59 -04:00
<style>{`
2025-08-20 07:32:40 -04:00
/* Table and Dark Overrides */
2025-07-31 19:28:59 -04:00
.p-datatable {
2025-08-20 07:32:40 -04:00
table-layout: fixed !important;
2025-07-31 19:28:59 -04:00
}
2025-08-20 07:32:40 -04:00
.p-datatable td span.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-datatable {
2025-08-20 07:32:40 -04:00
background-color: #121212 !important;
color: #e5e7eb !important;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-datatable-thead > tr > th {
2025-08-20 07:32:40 -04:00
background-color: #1f1f1f !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-datatable-tbody > tr {
2025-08-20 07:32:40 -04:00
background-color: #1a1a1a !important;
border-bottom: 1px solid #374151;
color: #e5e7eb !important;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
2025-08-20 07:32:40 -04:00
background-color: #222 !important;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-datatable-tbody > tr:hover {
2025-08-20 07:32:40 -04:00
background-color: #333 !important;
2025-07-31 19:28:59 -04:00
color: #fff !important;
}
2025-08-20 07:32:40 -04:00
/* Paginator Dark Mode */
2025-07-31 19:28:59 -04:00
[data-theme="dark"] .p-paginator {
2025-08-20 07:32:40 -04:00
background-color: #121212 !important;
color: #e5e7eb !important;
border-top: 1px solid #374151 !important;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-paginator .p-paginator-page,
[data-theme="dark"] .p-paginator .p-paginator-next,
2025-08-20 07:32:40 -04:00
[data-theme="dark"] .p-paginator .p-paginator-prev,
[data-theme="dark"] .p-paginator .p-paginator-first,
[data-theme="dark"] .p-paginator .p-paginator-last {
color: #e5e7eb !important;
background: transparent !important;
border: none !important;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
2025-08-20 07:32:40 -04:00
background-color: #374151 !important;
color: #fff !important;
border-radius: 0.25rem;
2025-07-31 19:28:59 -04:00
}
[data-theme="dark"] .p-paginator .p-highlight {
2025-08-20 07:32:40 -04:00
background-color: #6b7280 !important;
color: #fff !important;
border-radius: 0.25rem !important;
2025-07-31 20:25:02 -04:00
}
2025-08-20 07:32:40 -04:00
/* Dark mode for PrimeReact Dialog */
[data-theme="dark"] .p-dialog {
2025-08-20 07:32:40 -04:00
background-color: #1a1a1a !important;
color: #e5e7eb !important;
border-color: #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-header {
2025-08-20 07:32:40 -04:00
background-color: #121212 !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-content {
2025-08-20 07:32:40 -04:00
background-color: #1a1a1a !important;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-dialog .p-dialog-footer {
2025-08-20 07:32:40 -04:00
background-color: #121212 !important;
border-top: 1px solid #374151 !important;
color: #e5e7eb !important;
}
2025-08-20 07:32:40 -04:00
`}</style>
2025-07-31 19:28:59 -04:00
<BreadcrumbNav currentPage="management" />
2025-08-20 07:32:40 -04:00
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
2025-07-31 19:28:59 -04:00
<div className="flex flex-wrap gap-6 mb-6">
<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>
2025-08-20 07:32:40 -04:00
<div className="w-max overflow-x-auto rounded-lg">
<DataTable
value={filteredRequests}
paginator
rows={10}
removableSort
sortMode="multiple"
emptyMessage="No requests found."
responsiveLayout="scroll"
onRowClick={handleRowClick}
>
2025-08-20 15:57:59 -04:00
<Column field="id" header="ID" sortable style={{ width: "10rem" }} body={(row) => textWithEllipsis(row.id, "8rem")} />
2025-08-20 07:32:40 -04:00
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
2025-08-20 15:57:59 -04:00
<Column field="tracks" header="# Tracks" sortable style={{ width: "10rem" }} body={(row) => row.tracks} />
2025-08-20 07:32:40 -04:00
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
<Column
field="tarball"
header="Tarball"
body={(row) => {
const url = tarballUrl(row.tarball);
return url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline truncate block"
title={url.split("/").pop()}
>
{url.split("/").pop()}
</a>
) : (
"—"
);
}}
style={{ width: "18rem" }}
/>
</DataTable>
</div>
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-20 15:57:59 -04:00
{selectedRequest.id && (
<p><strong>ID:</strong> {selectedRequest.id}</p>
)}
{selectedRequest.target && (
<p><strong>Target:</strong> {selectedRequest.target}</p>
)}
{selectedRequest.tracks && (
<p><strong># Tracks:</strong> {selectedRequest.tracks}</p>
)}
{selectedRequest.status && (<p>
2025-08-20 07:32:40 -04:00
<strong>Status:</strong>{" "}
<span
2025-08-20 07:32:40 -04:00
className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}
>
{selectedRequest.status}
</span>
2025-08-20 15:57:59 -04:00
</p>)}
2025-08-20 07:32:40 -04:00
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
)}
2025-08-20 07:32:40 -04:00
{selectedRequest.enqueued_at && (
<p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>
)}
{selectedRequest.started_at && (
<p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>
)}
{selectedRequest.ended_at && (
<p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>
)}
{selectedRequest.tarball && (
<p>
<strong>Tarball:</strong>{" "}
<a
href={tarballUrl(selectedRequest.tarball)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{tarballUrl(selectedRequest.tarball).split("/").pop()}
</a>
</p>
)}
</div>
2025-08-20 07:32:40 -04:00
) : (
<p>Loading...</p>
)}
</Dialog>
2025-07-31 19:28:59 -04:00
</div>
);
}