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";
|
2025-08-01 15:17:25 -04:00
|
|
|
import { Dialog } from "primereact/dialog";
|
2025-08-21 15:07:10 -04:00
|
|
|
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";
|
2025-12-05 14:21:52 -05:00
|
|
|
import "./RequestManagement.css";
|
2025-07-31 19:28:59 -04:00
|
|
|
|
2026-01-25 13:11:25 -05:00
|
|
|
interface TrackInfo {
|
|
|
|
|
title?: string;
|
|
|
|
|
artist?: string;
|
|
|
|
|
status?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
filename?: string;
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
interface RequestJob {
|
|
|
|
|
id: string | number;
|
|
|
|
|
target: string;
|
|
|
|
|
tracks: number;
|
|
|
|
|
quality: string;
|
|
|
|
|
status: string;
|
2025-12-24 07:50:15 -05:00
|
|
|
progress: number | string | null;
|
2025-12-19 11:59:00 -05:00
|
|
|
type?: string;
|
2025-12-24 07:50:15 -05:00
|
|
|
tarball?: string;
|
2025-12-19 11:59:00 -05:00
|
|
|
created_at?: string;
|
|
|
|
|
updated_at?: string;
|
2026-01-25 13:11:25 -05:00
|
|
|
track_list?: TrackInfo[];
|
2025-12-19 11:59:00 -05:00
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 11:15:17 -04:00
|
|
|
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
2026-02-07 21:17:41 -05:00
|
|
|
const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix
|
2025-08-01 15:17:25 -04:00
|
|
|
|
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);
|
2025-08-01 15:17:25 -04:00
|
|
|
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
2025-12-05 14:21:52 -05:00
|
|
|
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);
|
2026-01-25 13:11:25 -05:00
|
|
|
// Track finalizing job polls to actively refresh job status when progress hits 100% but status hasn't updated yet
|
|
|
|
|
const finalizingPollsRef = useRef<Record<string | number, ReturnType<typeof setInterval> | null>>({});
|
2025-08-20 07:32:40 -04:00
|
|
|
|
|
|
|
|
|
2025-12-24 07:50:15 -05: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"
|
2025-12-24 07:50:15 -05:00
|
|
|
// If the backend already stores a fully qualified URL, return as-is
|
|
|
|
|
if (/^https?:\/\//i.test(absPath)) return absPath;
|
2026-01-25 13:11:25 -05:00
|
|
|
|
|
|
|
|
// Check if path is /storage/music/TRIP
|
|
|
|
|
if (absPath.includes("/storage/music/TRIP/")) {
|
|
|
|
|
return `https://music.boatson.boats/TRIP/${filename}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise, assume /storage/music2/completed/{quality} format
|
2025-08-21 15:07:10 -04:00
|
|
|
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
2025-08-20 07:32:40 -04:00
|
|
|
};
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
const fetchJobs = async (showLoading = true) => {
|
2025-08-20 07:32:40 -04:00
|
|
|
try {
|
2025-12-05 14:21:52 -05:00
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-05 14:21:52 -05:00
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
2025-08-20 07:32:40 -04:00
|
|
|
}
|
|
|
|
|
};
|
2025-08-01 15:17:25 -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);
|
2025-08-21 15:07:10 -04:00
|
|
|
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) => {
|
2025-08-01 15:29:27 -04:00
|
|
|
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-08-01 15:29:27 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
const getQualityColorClass = (quality: string) => {
|
2025-08-21 15:07:10 -04:00
|
|
|
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-08-21 15:07:10 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
const statusBodyTemplate = (rowData: RequestJob) => (
|
2025-12-05 14:21:52 -05:00
|
|
|
<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) => (
|
2025-12-05 14:21:52 -05:00
|
|
|
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
|
2025-08-21 15:07:10 -04:00
|
|
|
{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) =>
|
2025-08-21 15:07:10 -04:00
|
|
|
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 "—";
|
2025-12-24 07:50:15 -05:00
|
|
|
const pct = computePct(p);
|
2025-08-20 07:32:40 -04:00
|
|
|
return `${pct}%`;
|
|
|
|
|
};
|
2025-08-01 15:29:27 -04:00
|
|
|
|
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;
|
2026-01-25 13:11:25 -05:00
|
|
|
// Handle "X / Y" format (e.g., "9 / 545") - note spaces around slash from backend
|
|
|
|
|
if (typeof p === 'string' && p.includes('/')) {
|
|
|
|
|
const parts = p.split('/').map(s => s.trim());
|
|
|
|
|
const current = parseFloat(parts[0]);
|
|
|
|
|
const total = parseFloat(parts[1]);
|
|
|
|
|
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
|
|
|
|
|
return Math.min(100, Math.max(0, Math.round((current / total) * 100)));
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2025-12-17 13:33:31 -05:00
|
|
|
const num = Number(p);
|
2025-12-24 07:50:15 -05:00
|
|
|
if (!Number.isFinite(num)) return 0;
|
2026-01-25 13:11:25 -05:00
|
|
|
// Backend sends progress as 0-100 directly, so just clamp it
|
|
|
|
|
return Math.min(100, Math.max(0, Math.round(num)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Visual pct used for display/fill. Prevent briefly showing 100% unless status is Finished
|
|
|
|
|
const displayPct = (p: unknown, status?: string) => {
|
|
|
|
|
const pct = computePct(p);
|
|
|
|
|
const statusNorm = String(status || "").trim();
|
|
|
|
|
// If the backend reports 100% but the job hasn't reached 'Finished', show 99 to avoid flash
|
|
|
|
|
if (pct >= 100 && statusNorm.toLowerCase() !== 'finished') return 99;
|
|
|
|
|
return pct;
|
|
|
|
|
};
|
|
|
|
|
const isFinalizingJob = (job: RequestJob | { progress?: unknown; status?: string }) => {
|
|
|
|
|
const pct = computePct(job.progress);
|
|
|
|
|
const statusNorm = String(job.status || "").trim().toLowerCase();
|
|
|
|
|
// Only treat as finalizing when status is explicitly "Compressing"
|
|
|
|
|
// This is set by the backend only when progress == 100 and tarball isn't ready yet
|
|
|
|
|
return statusNorm === 'compressing';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startFinalizingPoll = (jobId: string | number) => {
|
|
|
|
|
if (finalizingPollsRef.current[jobId]) return; // already polling
|
|
|
|
|
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
const iv = setInterval(async () => {
|
|
|
|
|
attempts += 1;
|
|
|
|
|
try {
|
|
|
|
|
const updated = await fetchJobDetail(jobId);
|
|
|
|
|
if (updated) {
|
|
|
|
|
// Merge the updated job into requests list so UI refreshes
|
|
|
|
|
setRequests((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
|
|
|
|
// If it's no longer finalizing, stop this poll
|
|
|
|
|
if (!isFinalizingJob(updated)) {
|
|
|
|
|
if (finalizingPollsRef.current[jobId]) {
|
|
|
|
|
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
|
|
|
|
finalizingPollsRef.current[jobId] = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// ignore individual errors; we'll retry a few times
|
|
|
|
|
}
|
|
|
|
|
// safety cap: stop after ~20 attempts (~30s)
|
|
|
|
|
if (attempts >= 20) {
|
|
|
|
|
if (finalizingPollsRef.current[jobId]) {
|
|
|
|
|
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
|
|
|
|
finalizingPollsRef.current[jobId] = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 1500);
|
|
|
|
|
|
|
|
|
|
finalizingPollsRef.current[jobId] = iv;
|
2025-12-17 13:33:31 -05:00
|
|
|
};
|
|
|
|
|
|
2026-01-25 13:11:25 -05:00
|
|
|
// stop all finalizing polls on unmount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
Object.values(finalizingPollsRef.current).forEach((iv) => {
|
|
|
|
|
if (iv) clearInterval(iv);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
const progressBarTemplate = (rowData: RequestJob) => {
|
2025-12-05 14:21:52 -05:00
|
|
|
const p = rowData.progress;
|
2025-12-24 07:50:15 -05:00
|
|
|
if (p === null || p === undefined || p === "") return "—";
|
2026-01-25 13:11:25 -05:00
|
|
|
const pctRaw = computePct(p);
|
|
|
|
|
const isFinalizing = isFinalizingJob(rowData);
|
|
|
|
|
const pct = isFinalizing ? 99 : pctRaw;
|
2025-12-05 14:21:52 -05:00
|
|
|
|
|
|
|
|
const getProgressColor = () => {
|
|
|
|
|
if (rowData.status === "Failed") return "bg-red-500";
|
|
|
|
|
if (rowData.status === "Finished") return "bg-green-500";
|
2026-01-25 13:11:25 -05:00
|
|
|
if (isFinalizing) return "bg-yellow-500"; // finalizing indicator
|
|
|
|
|
if (pctRaw < 30) return "bg-blue-400";
|
|
|
|
|
if (pctRaw < 70) return "bg-blue-500";
|
2025-12-05 14:21:52 -05:00
|
|
|
return "bg-blue-600";
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-25 13:11:25 -05:00
|
|
|
// If this job appears to be finalizing, ensure a poll is active to get the real status
|
|
|
|
|
if (isFinalizing) startFinalizingPoll(rowData.id);
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
return (
|
2025-12-17 13:33:31 -05:00
|
|
|
<div className="rm-progress-container">
|
|
|
|
|
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
|
2025-12-05 14:21:52 -05:00
|
|
|
<div
|
2026-01-25 13:11:25 -05:00
|
|
|
className={`rm-progress-fill ${getProgressColor()} ${isFinalizing ? 'rm-finalizing' : ''}`}
|
2025-12-17 13:33:31 -05:00
|
|
|
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}
|
2026-01-25 13:11:25 -05:00
|
|
|
aria-valuenow={pctRaw}
|
2025-12-17 13:33:31 -05:00
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
2025-12-05 14:21:52 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-25 13:11:25 -05:00
|
|
|
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%{isFinalizing ? ' (finalizing...)' : ''}</span>
|
2025-12-05 14:21:52 -05:00
|
|
|
</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
|
|
|
|
2025-12-05 14:21:52 -05: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
|
2025-12-05 14:21:52 -05:00
|
|
|
border border-neutral-200 dark:border-neutral-700">
|
2025-08-01 15:17:25 -04:00
|
|
|
|
2025-07-31 19:28:59 -04:00
|
|
|
<BreadcrumbNav currentPage="management" />
|
2025-12-05 14:21:52 -05:00
|
|
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
|
2025-07-31 19:28:59 -04:00
|
|
|
|
2025-12-05 14:21:52 -05: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>
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
{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
|
|
|
}
|
2025-12-05 14:21:52 -05: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]}
|
2025-12-05 14:21:52 -05:00
|
|
|
</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} />
|
2025-12-05 14:21:52 -05:00
|
|
|
<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) => {
|
2025-12-24 07:50:15 -05:00
|
|
|
const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC");
|
2025-12-05 14:21:52 -05:00
|
|
|
if (!url) return "—";
|
2025-12-19 11:59:00 -05:00
|
|
|
const encodedURL = encodeURI(url);
|
2025-12-05 14:21:52 -05:00
|
|
|
|
2025-12-19 11:59:00 -05:00
|
|
|
const fileName = url.split("/").pop() || "";
|
2025-12-05 14:21:52 -05:00
|
|
|
|
|
|
|
|
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
|
|
|
|
2025-08-01 15:17:25 -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" }}
|
2025-08-01 15:29:27 -04:00
|
|
|
modal
|
|
|
|
|
dismissableMask
|
2025-08-20 07:32:40 -04:00
|
|
|
className="dark:bg-neutral-900 dark:text-neutral-100"
|
2025-08-01 15:17:25 -04:00
|
|
|
>
|
2025-08-20 07:32:40 -04:00
|
|
|
{selectedRequest ? (
|
2025-08-01 15:17:25 -04:00
|
|
|
<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 && (
|
2025-12-05 14:21:52 -05:00
|
|
|
<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">
|
2025-12-24 07:50:15 -05:00
|
|
|
{(() => {
|
2026-01-25 13:11:25 -05:00
|
|
|
const pctRawDialog = computePct(selectedRequest.progress);
|
|
|
|
|
const isFinalizingDialog = isFinalizingJob(selectedRequest);
|
|
|
|
|
const pctDialog = isFinalizingDialog ? 99 : pctRawDialog;
|
2025-12-24 07:50:15 -05:00
|
|
|
const status = selectedRequest.status;
|
2026-01-25 13:11:25 -05:00
|
|
|
const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : isFinalizingDialog ? "bg-yellow-500" : "bg-blue-500";
|
|
|
|
|
|
|
|
|
|
// Ensure we poll for finalizing jobs to get the real status update
|
|
|
|
|
if (isFinalizingDialog) startFinalizingPoll(selectedRequest.id);
|
|
|
|
|
|
2025-12-24 07:50:15 -05:00
|
|
|
return (
|
2026-01-25 13:11:25 -05:00
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
className={`rm-progress-fill ${fillColor} ${isFinalizingDialog ? 'rm-finalizing' : ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
['--rm-progress' as string]: (pctDialog / 100).toString(),
|
|
|
|
|
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
|
|
|
|
|
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
|
|
|
|
|
}}
|
|
|
|
|
data-pct={pctDialog}
|
|
|
|
|
aria-valuenow={pctRawDialog}
|
|
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
2025-12-24 07:50:15 -05:00
|
|
|
);
|
|
|
|
|
})()}
|
2025-12-05 14:21:52 -05:00
|
|
|
</div>
|
2026-01-25 13:11:25 -05:00
|
|
|
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}{isFinalizingJob(selectedRequest) ? ' — finalizing' : ''}</span>
|
2025-12-05 14:21:52 -05:00
|
|
|
</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 --- */}
|
|
|
|
|
{
|
2025-12-24 07:50:15 -05:00
|
|
|
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
|
2025-12-24 07:50:15 -05:00
|
|
|
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"
|
|
|
|
|
>
|
2025-12-24 07:50:15 -05:00
|
|
|
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality)?.split("/").pop()}
|
2025-08-28 11:15:17 -04:00
|
|
|
</a>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 13:11:25 -05:00
|
|
|
{/* --- Track List Card --- */}
|
|
|
|
|
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
|
|
|
|
|
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
|
|
|
|
|
<p className="mb-2"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
|
|
|
|
|
<div className="max-h-60 overflow-y-auto space-y-2" data-lenis-prevent>
|
|
|
|
|
{selectedRequest.track_list.map((track, idx) => {
|
|
|
|
|
const rawStatus = String(track.status || "pending");
|
|
|
|
|
const statusNorm = rawStatus.trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
const isError = statusNorm === "failed" || statusNorm === "error" || !!track.error;
|
|
|
|
|
const isSuccess = ["done", "success", "completed", "finished"].includes(statusNorm);
|
|
|
|
|
const isPending = ["pending", "queued"].includes(statusNorm);
|
|
|
|
|
const isDownloading = ["downloading", "in_progress", "started"].includes(statusNorm);
|
|
|
|
|
|
|
|
|
|
const statusBadgeClass = isError
|
|
|
|
|
? "bg-red-600 text-white"
|
|
|
|
|
: isSuccess
|
|
|
|
|
? "bg-green-600 text-white"
|
|
|
|
|
: isDownloading
|
|
|
|
|
? "bg-blue-600 text-white"
|
|
|
|
|
: isPending
|
|
|
|
|
? "bg-yellow-600 text-white"
|
|
|
|
|
: "bg-gray-500 text-white";
|
|
|
|
|
|
|
|
|
|
const trackTitle = track.title || track.filename || `Track ${idx + 1}`;
|
|
|
|
|
const trackArtist = track.artist;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={idx}
|
|
|
|
|
className={`p-2 rounded border ${isError ? "border-red-500/50 bg-red-500/10" : "border-neutral-300 dark:border-neutral-600"}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="text-sm font-medium truncate" title={trackTitle}>
|
|
|
|
|
{trackTitle}
|
|
|
|
|
</p>
|
|
|
|
|
{trackArtist && (
|
|
|
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={trackArtist}>
|
|
|
|
|
{trackArtist}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className={`px-2 py-0.5 pr-3 mr-2 rounded text-xs font-semibold whitespace-nowrap ${statusBadgeClass} rm-track-status`} title={rawStatus}>
|
|
|
|
|
{rawStatus}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{track.error && (
|
|
|
|
|
<p className="mt-1 text-xs text-red-400 break-words">
|
|
|
|
|
<i className="pi pi-exclamation-triangle mr-1" />
|
|
|
|
|
{track.error}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-08-28 11:15:17 -04:00
|
|
|
</div >
|
2025-08-20 07:32:40 -04:00
|
|
|
) : (
|
|
|
|
|
<p>Loading...</p>
|
2025-08-21 15:07:10 -04:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
</Dialog >
|
2025-08-01 15:17:25 -04:00
|
|
|
|
2025-08-28 11:15:17 -04:00
|
|
|
|
2025-08-21 15:07:10 -04:00
|
|
|
</div >
|
2025-07-31 19:28:59 -04:00
|
|
|
);
|
|
|
|
|
}
|