mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
1775 lines
97 KiB
TypeScript
1775 lines
97 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
Search, RefreshCw, Trash2, Ban,
|
|
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
|
|
} from "lucide-react";
|
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
|
import { useDebouncedValue } from "../lib/useDebouncedValue";
|
|
import { showToast } from "../lib/toast";
|
|
import ConfirmDialog from "./ui/ConfirmDialog";
|
|
import { clsx, type ClassValue } from "clsx";
|
|
import { twMerge } from "tailwind-merge";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { withErrorBoundary } from "./ErrorBoundary";
|
|
|
|
function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
function focusableElements(root: HTMLElement): HTMLElement[] {
|
|
const selector = [
|
|
"a[href]",
|
|
"button:not([disabled])",
|
|
"input:not([disabled])",
|
|
"select:not([disabled])",
|
|
"textarea:not([disabled])",
|
|
"[tabindex]:not([tabindex='-1'])",
|
|
].join(",");
|
|
|
|
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
|
|
(element) => !element.hasAttribute("disabled")
|
|
);
|
|
}
|
|
|
|
export interface SkipDetail {
|
|
summary: string;
|
|
detail: string;
|
|
action: string | null;
|
|
measured: Record<string, string>;
|
|
}
|
|
|
|
function formatReductionPercent(value?: string): string {
|
|
if (!value) {
|
|
return "?";
|
|
}
|
|
|
|
const parsed = Number.parseFloat(value);
|
|
return Number.isFinite(parsed) ? `${(parsed * 100).toFixed(0)}%` : value;
|
|
}
|
|
|
|
export function humanizeSkipReason(reason: string): SkipDetail {
|
|
const pipeIdx = reason.indexOf("|");
|
|
const key = pipeIdx === -1
|
|
? reason.trim()
|
|
: reason.slice(0, pipeIdx).trim();
|
|
const paramStr = pipeIdx === -1 ? "" : reason.slice(pipeIdx + 1);
|
|
|
|
const measured: Record<string, string> = {};
|
|
for (const pair of paramStr.split(",")) {
|
|
const [rawKey, ...rawValueParts] = pair.split("=");
|
|
if (!rawKey || rawValueParts.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
measured[rawKey.trim()] = rawValueParts.join("=").trim();
|
|
}
|
|
|
|
const fallbackDisabledMatch = key.match(
|
|
/^Preferred codec\s+(.+?)\s+unavailable and fallback disabled$/i
|
|
);
|
|
if (fallbackDisabledMatch) {
|
|
measured.codec ??= fallbackDisabledMatch[1];
|
|
return {
|
|
summary: "Preferred encoder unavailable",
|
|
detail: `The preferred codec (${measured.codec ?? "target codec"}) is not available and CPU fallback is disabled in settings.`,
|
|
action: "Go to Settings -> Hardware and enable CPU fallback, or check that your GPU encoder is working correctly.",
|
|
measured,
|
|
};
|
|
}
|
|
|
|
switch (key) {
|
|
case "analysis_failed":
|
|
return {
|
|
summary: "File could not be analyzed",
|
|
detail: `FFprobe failed to read this file. It may be corrupt, incomplete, or in an unsupported format. Error: ${measured.error ?? "unknown"}`,
|
|
action: "Try playing the file in VLC or another media player. If it plays fine, re-run the scan. If not, the file may be damaged.",
|
|
measured,
|
|
};
|
|
|
|
case "planning_failed":
|
|
return {
|
|
summary: "Transcoding plan could not be created",
|
|
detail: `An internal error occurred while planning the transcode for this file. This is likely a bug. Error: ${measured.error ?? "unknown"}`,
|
|
action: "Check the logs below for details. If this happens repeatedly, please report it as a bug.",
|
|
measured,
|
|
};
|
|
|
|
case "already_target_codec":
|
|
return {
|
|
summary: "Already in target format",
|
|
detail: `This file is already encoded as ${measured.codec ?? "the target codec"}${measured.bit_depth ? ` at ${measured.bit_depth}-bit` : ""}. Re-encoding would waste time and could reduce quality.`,
|
|
action: null,
|
|
measured,
|
|
};
|
|
|
|
case "already_target_codec_wrong_container":
|
|
return {
|
|
summary: "Target codec, wrong container",
|
|
detail: `The video is already in the right codec but wrapped in a ${measured.container ?? "MP4"} container. Alchemist will remux it to ${measured.target_extension ?? "MKV"} - fast and lossless, no quality loss.`,
|
|
action: null,
|
|
measured,
|
|
};
|
|
|
|
case "bpp_below_threshold":
|
|
return {
|
|
summary: "Already efficiently compressed",
|
|
detail: `Bits-per-pixel (${measured.bpp ?? "?"}) is below the minimum threshold (${measured.threshold ?? "?"}). This file is already well-compressed - transcoding it would spend significant time for minimal space savings.`,
|
|
action: "If you want to force transcoding, lower the BPP threshold in Settings -> Transcoding.",
|
|
measured,
|
|
};
|
|
|
|
case "below_min_file_size":
|
|
return {
|
|
summary: "File too small to process",
|
|
detail: `File size (${measured.size_mb ?? "?"}MB) is below the minimum threshold (${measured.threshold_mb ?? "?"}MB). Small files aren't worth the transcoding overhead.`,
|
|
action: "Lower the minimum file size threshold in Settings -> Transcoding if you want small files processed.",
|
|
measured,
|
|
};
|
|
|
|
case "size_reduction_insufficient":
|
|
return {
|
|
summary: "Not enough space would be saved",
|
|
detail: `The predicted size reduction (${formatReductionPercent(measured.predicted)}) is below the required threshold (${formatReductionPercent(measured.threshold)}). Transcoding this file wouldn't recover meaningful storage.`,
|
|
action: "Lower the size reduction threshold in Settings -> Transcoding to encode files with smaller savings.",
|
|
measured,
|
|
};
|
|
|
|
case "no_suitable_encoder":
|
|
return {
|
|
summary: "No encoder available",
|
|
detail: `No encoder was found for ${measured.codec ?? "the target codec"}. Hardware detection may have failed, or CPU fallback is disabled.`,
|
|
action: "Check Settings -> Hardware. Enable CPU fallback, or verify your GPU is detected correctly.",
|
|
measured,
|
|
};
|
|
|
|
case "Output path matches input path":
|
|
return {
|
|
summary: "Output would overwrite source",
|
|
detail: "The configured output path is the same as the source file. Alchemist refused to proceed to avoid overwriting your original file.",
|
|
action: "Go to Settings -> Files and configure a different output suffix or output folder.",
|
|
measured,
|
|
};
|
|
|
|
case "Output already exists":
|
|
return {
|
|
summary: "Output file already exists",
|
|
detail: "A transcoded version of this file already exists at the output path. Alchemist skipped it to avoid duplicating work.",
|
|
action: "If you want to re-transcode it, delete the existing output file first, then retry the job.",
|
|
measured,
|
|
};
|
|
|
|
case "incomplete_metadata":
|
|
return {
|
|
summary: "Missing file metadata",
|
|
detail: `FFprobe could not determine the ${measured.missing ?? "required metadata"} for this file. Without reliable metadata Alchemist cannot make a valid transcoding decision.`,
|
|
action: "Run a Library Doctor scan to check if this file is corrupt. Try playing it in a media player to confirm it is readable.",
|
|
measured,
|
|
};
|
|
|
|
case "already_10bit":
|
|
return {
|
|
summary: "Already 10-bit",
|
|
detail: "This file is already encoded in high-quality 10-bit depth. Re-encoding it could reduce quality.",
|
|
action: null,
|
|
measured,
|
|
};
|
|
|
|
case "remux: mp4_to_mkv_stream_copy":
|
|
return {
|
|
summary: "Remuxed (no re-encode)",
|
|
detail: "This file was remuxed from MP4 to MKV using stream copy - fast and lossless. No quality was lost.",
|
|
action: null,
|
|
measured,
|
|
};
|
|
|
|
case "Low quality (VMAF)":
|
|
return {
|
|
summary: "Quality check failed",
|
|
detail: "The encoded file scored below the minimum VMAF quality threshold. Alchemist rejected the output to protect quality.",
|
|
action: "The original file has been preserved. You can lower the VMAF threshold in Settings -> Quality, or disable VMAF checking entirely.",
|
|
measured,
|
|
};
|
|
|
|
default:
|
|
return {
|
|
summary: "Decision recorded",
|
|
detail: reason,
|
|
action: null,
|
|
measured,
|
|
};
|
|
}
|
|
}
|
|
|
|
function explainFailureSummary(summary: string): string {
|
|
const normalized = summary.toLowerCase();
|
|
|
|
if (normalized.includes("cancelled")) {
|
|
return "This job was cancelled before encoding completed. The original file is untouched.";
|
|
}
|
|
if (normalized.includes("no such file or directory")) {
|
|
return "The source file could not be found. It may have been moved or deleted.";
|
|
}
|
|
if (normalized.includes("invalid data found") || normalized.includes("moov atom not found")) {
|
|
return "This file appears to be corrupt or incomplete. Try running a Library Doctor scan.";
|
|
}
|
|
if (normalized.includes("permission denied")) {
|
|
return "Alchemist doesn't have permission to read this file. Check the file permissions.";
|
|
}
|
|
if (normalized.includes("encoder not found") || normalized.includes("unknown encoder")) {
|
|
return "The required encoder is not available in your FFmpeg installation.";
|
|
}
|
|
if (normalized.includes("out of memory") || normalized.includes("cannot allocate memory")) {
|
|
return "The system ran out of memory during encoding. Try reducing concurrent jobs.";
|
|
}
|
|
if (normalized.includes("transcode_failed") || normalized.includes("ffmpeg exited")) {
|
|
return "FFmpeg failed during encoding. This is often caused by a corrupt source file or an encoder configuration issue. Check the logs below for the specific FFmpeg error.";
|
|
}
|
|
if (normalized.includes("probing failed")) {
|
|
return "FFprobe could not read this file. It may be corrupt or in an unsupported format.";
|
|
}
|
|
if (normalized.includes("planning_failed") || normalized.includes("planner")) {
|
|
return "An error occurred while planning the transcode. Check the logs below for details.";
|
|
}
|
|
if (normalized.includes("output_size=0") || normalized.includes("output was empty")) {
|
|
return "Encoding produced an empty output file. This usually means FFmpeg crashed silently. Check the logs below for FFmpeg output.";
|
|
}
|
|
|
|
if (
|
|
normalized.includes("videotoolbox") ||
|
|
normalized.includes("vt_compression") ||
|
|
normalized.includes("err=-12902") ||
|
|
normalized.includes("mediaserverd") ||
|
|
normalized.includes("no capable devices")
|
|
) {
|
|
return "The VideoToolbox hardware encoder failed. This can happen when the GPU is busy, the file uses an unsupported pixel format, or macOS Media Services are unavailable. Retry the job — if it keeps failing, CPU fallback is available in Settings → Hardware.";
|
|
}
|
|
|
|
if (
|
|
normalized.includes("encoder fallback") ||
|
|
normalized.includes("fallback detected")
|
|
) {
|
|
return "The hardware encoder was unavailable and fell back to software encoding, which was not allowed by your settings. Enable CPU fallback in Settings → Hardware, or retry when the GPU is less busy.";
|
|
}
|
|
|
|
if (normalized.includes("ffmpeg failed")) {
|
|
return "FFmpeg failed during encoding. Check the logs below for the specific error. Common causes: unsupported pixel format, codec not available, or corrupt source file.";
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
function logLevelClass(level: string): string {
|
|
switch (level.toLowerCase()) {
|
|
case "error":
|
|
return "text-status-error";
|
|
case "warn":
|
|
case "warning":
|
|
return "text-helios-solar";
|
|
default:
|
|
return "text-helios-slate";
|
|
}
|
|
}
|
|
|
|
interface Job {
|
|
id: number;
|
|
input_path: string;
|
|
output_path: string;
|
|
status: string;
|
|
priority: number;
|
|
progress: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
attempt_count: number;
|
|
vmaf_score?: number;
|
|
decision_reason?: string;
|
|
encoder?: string;
|
|
}
|
|
|
|
function retryCountdown(job: Job): string | null {
|
|
if (job.status !== "failed") return null;
|
|
if (!job.attempt_count || job.attempt_count === 0) return null;
|
|
|
|
const backoffMins =
|
|
job.attempt_count === 1 ? 5
|
|
: job.attempt_count === 2 ? 15
|
|
: job.attempt_count === 3 ? 60
|
|
: 360;
|
|
|
|
const updatedMs = new Date(job.updated_at).getTime();
|
|
const retryAtMs = updatedMs + backoffMins * 60 * 1000;
|
|
const remainingMs = retryAtMs - Date.now();
|
|
|
|
if (remainingMs <= 0) return "Retrying soon";
|
|
|
|
const remainingMins = Math.ceil(remainingMs / 60_000);
|
|
if (remainingMins < 60) return `Retrying in ${remainingMins}m`;
|
|
const hrs = Math.floor(remainingMins / 60);
|
|
const mins = remainingMins % 60;
|
|
return mins > 0 ? `Retrying in ${hrs}h ${mins}m` : `Retrying in ${hrs}h`;
|
|
}
|
|
|
|
interface JobMetadata {
|
|
duration_secs: number;
|
|
codec_name: string;
|
|
width: number;
|
|
height: number;
|
|
bit_depth?: number;
|
|
size_bytes: number;
|
|
video_bitrate_bps?: number;
|
|
container_bitrate_bps?: number;
|
|
fps: number;
|
|
container: string;
|
|
audio_codec?: string;
|
|
audio_channels?: number;
|
|
dynamic_range?: string;
|
|
}
|
|
|
|
interface EncodeStats {
|
|
input_size_bytes: number;
|
|
output_size_bytes: number;
|
|
compression_ratio: number;
|
|
encode_time_seconds: number;
|
|
encode_speed: number;
|
|
avg_bitrate_kbps: number;
|
|
vmaf_score?: number;
|
|
}
|
|
|
|
interface LogEntry {
|
|
id: number;
|
|
level: string;
|
|
message: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface JobDetail {
|
|
job: Job;
|
|
metadata: JobMetadata | null;
|
|
encode_stats: EncodeStats | null;
|
|
job_logs: LogEntry[];
|
|
job_failure_summary: string | null;
|
|
}
|
|
|
|
interface CountMessageResponse {
|
|
count: number;
|
|
message: string;
|
|
}
|
|
|
|
type TabType = "all" | "active" | "queued" | "completed" | "failed" | "skipped" | "archived";
|
|
type SortField = "updated_at" | "created_at" | "input_path" | "size";
|
|
|
|
const SORT_OPTIONS: Array<{ value: SortField; label: string }> = [
|
|
{ value: "updated_at", label: "Last Updated" },
|
|
{ value: "created_at", label: "Date Added" },
|
|
{ value: "input_path", label: "File Name" },
|
|
{ value: "size", label: "File Size" },
|
|
];
|
|
|
|
function JobManager() {
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
const [activeTab, setActiveTab] = useState<TabType>("all");
|
|
const [searchInput, setSearchInput] = useState("");
|
|
const debouncedSearch = useDebouncedValue(searchInput, 350);
|
|
const [page, setPage] = useState(1);
|
|
const [sortBy, setSortBy] = useState<SortField>("updated_at");
|
|
const [sortDesc, setSortDesc] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [focusedJob, setFocusedJob] = useState<JobDetail | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const detailDialogRef = useRef<HTMLDivElement | null>(null);
|
|
const detailLastFocusedRef = useRef<HTMLElement | null>(null);
|
|
const confirmOpenRef = useRef(false);
|
|
const encodeStartTimes = useRef<Map<number, number>>(new Map());
|
|
const [confirmState, setConfirmState] = useState<{
|
|
title: string;
|
|
body: string;
|
|
confirmLabel: string;
|
|
confirmTone?: "danger" | "primary";
|
|
onConfirm: () => Promise<void> | void;
|
|
} | null>(null);
|
|
const [tick, setTick] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setTick(t => t + 1), 30_000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
|
|
|
|
const formatJobActionError = (error: unknown, fallback: string) => {
|
|
if (!isApiError(error)) {
|
|
return fallback;
|
|
}
|
|
|
|
const blocked = Array.isArray((error.body as { blocked?: unknown } | undefined)?.blocked)
|
|
? ((error.body as { blocked?: Array<{ id?: number; status?: string }> }).blocked ?? [])
|
|
: [];
|
|
if (blocked.length === 0) {
|
|
return error.message;
|
|
}
|
|
|
|
const summary = blocked
|
|
.map((job) => `#${job.id ?? "?"} (${job.status ?? "unknown"})`)
|
|
.join(", ");
|
|
return `${error.message}: ${summary}`;
|
|
};
|
|
|
|
// Filter mapping
|
|
const getStatusFilter = (tab: TabType) => {
|
|
switch (tab) {
|
|
case "active": return ["analyzing", "encoding", "remuxing", "resuming"];
|
|
case "queued": return ["queued"];
|
|
case "completed": return ["completed"];
|
|
case "failed": return ["failed", "cancelled"];
|
|
case "skipped": return ["skipped"];
|
|
default: return [];
|
|
}
|
|
};
|
|
|
|
const fetchJobs = useCallback(async (silent = false) => {
|
|
if (!silent) {
|
|
setRefreshing(true);
|
|
}
|
|
try {
|
|
const params = new URLSearchParams({
|
|
limit: "50",
|
|
page: page.toString(),
|
|
sort: sortBy,
|
|
sort_desc: String(sortDesc),
|
|
archived: String(activeTab === "archived"),
|
|
});
|
|
params.set("sort_by", sortBy);
|
|
|
|
const statusFilter = getStatusFilter(activeTab);
|
|
if (statusFilter.length > 0) {
|
|
params.set("status", statusFilter.join(","));
|
|
}
|
|
if (debouncedSearch) {
|
|
params.set("search", debouncedSearch);
|
|
}
|
|
|
|
const data = await apiJson<Job[]>(`/api/jobs/table?${params}`);
|
|
setJobs((prev) =>
|
|
data.map((serverJob) => {
|
|
const local = prev.find((j) => j.id === serverJob.id);
|
|
const terminal = ["completed", "skipped", "failed", "cancelled"];
|
|
const serverIsTerminal = terminal.includes(serverJob.status);
|
|
if (
|
|
local &&
|
|
terminal.includes(local.status) &&
|
|
serverIsTerminal
|
|
) {
|
|
// Both agree this is terminal — keep
|
|
// local status to prevent SSE→poll flicker.
|
|
return { ...serverJob, status: local.status };
|
|
}
|
|
// Server says it changed (e.g. retry queued it)
|
|
// — trust the server.
|
|
return serverJob;
|
|
})
|
|
);
|
|
setActionError(null);
|
|
} catch (e) {
|
|
const message = isApiError(e) ? e.message : "Failed to fetch jobs";
|
|
setActionError(message);
|
|
if (!silent) {
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
if (!silent) {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
}, [activeTab, debouncedSearch, page, sortBy, sortDesc]);
|
|
|
|
const fetchJobsRef = useRef<() => Promise<void>>(async () => undefined);
|
|
|
|
useEffect(() => {
|
|
fetchJobsRef.current = async () => {
|
|
await fetchJobs(true);
|
|
};
|
|
}, [fetchJobs]);
|
|
|
|
useEffect(() => {
|
|
void fetchJobs(false);
|
|
}, [fetchJobs]);
|
|
|
|
useEffect(() => {
|
|
const pollVisible = () => {
|
|
if (document.visibilityState === "visible") {
|
|
void fetchJobsRef.current();
|
|
}
|
|
};
|
|
|
|
const interval = window.setInterval(pollVisible, 5000);
|
|
document.addEventListener("visibilitychange", pollVisible);
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
document.removeEventListener("visibilitychange", pollVisible);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let eventSource: EventSource | null = null;
|
|
let cancelled = false;
|
|
let reconnectTimeout: number | null = null;
|
|
let reconnectAttempts = 0;
|
|
|
|
const getReconnectDelay = () => {
|
|
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
|
const baseDelay = 1000;
|
|
const maxDelay = 30000;
|
|
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), maxDelay);
|
|
// Add jitter (±25%) to prevent thundering herd
|
|
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
return Math.round(delay + jitter);
|
|
};
|
|
|
|
const connect = () => {
|
|
if (cancelled) return;
|
|
eventSource?.close();
|
|
eventSource = new EventSource("/api/events");
|
|
|
|
eventSource.onopen = () => {
|
|
// Reset reconnect attempts on successful connection
|
|
reconnectAttempts = 0;
|
|
};
|
|
|
|
eventSource.addEventListener("status", (e) => {
|
|
try {
|
|
const { job_id, status } = JSON.parse(e.data) as {
|
|
job_id: number;
|
|
status: string;
|
|
};
|
|
if (status === "encoding") {
|
|
encodeStartTimes.current.set(job_id, Date.now());
|
|
} else {
|
|
encodeStartTimes.current.delete(job_id);
|
|
}
|
|
setJobs((prev) =>
|
|
prev.map((job) =>
|
|
job.id === job_id ? { ...job, status } : job
|
|
)
|
|
);
|
|
} catch {
|
|
/* ignore malformed */
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener("progress", (e) => {
|
|
try {
|
|
const { job_id, percentage } = JSON.parse(e.data) as {
|
|
job_id: number;
|
|
percentage: number;
|
|
};
|
|
setJobs((prev) =>
|
|
prev.map((job) =>
|
|
job.id === job_id ? { ...job, progress: percentage } : job
|
|
)
|
|
);
|
|
} catch {
|
|
/* ignore malformed */
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener("decision", () => {
|
|
// Re-fetch full job list when decisions are made
|
|
void fetchJobsRef.current();
|
|
});
|
|
|
|
eventSource.onerror = () => {
|
|
eventSource?.close();
|
|
if (!cancelled) {
|
|
reconnectAttempts++;
|
|
const delay = getReconnectDelay();
|
|
reconnectTimeout = window.setTimeout(connect, delay);
|
|
}
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
eventSource?.close();
|
|
if (reconnectTimeout !== null) {
|
|
window.clearTimeout(reconnectTimeout);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const encodingJobIds = new Set<number>();
|
|
const now = Date.now();
|
|
|
|
for (const job of jobs) {
|
|
if (job.status !== "encoding") {
|
|
continue;
|
|
}
|
|
|
|
encodingJobIds.add(job.id);
|
|
if (!encodeStartTimes.current.has(job.id)) {
|
|
encodeStartTimes.current.set(job.id, now);
|
|
}
|
|
}
|
|
|
|
for (const jobId of Array.from(encodeStartTimes.current.keys())) {
|
|
if (!encodingJobIds.has(jobId)) {
|
|
encodeStartTimes.current.delete(jobId);
|
|
}
|
|
}
|
|
}, [jobs]);
|
|
|
|
useEffect(() => {
|
|
if (!menuJobId) return;
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setMenuJobId(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [menuJobId]);
|
|
|
|
useEffect(() => {
|
|
confirmOpenRef.current = confirmState !== null;
|
|
}, [confirmState]);
|
|
|
|
useEffect(() => {
|
|
if (!focusedJob) {
|
|
return;
|
|
}
|
|
|
|
detailLastFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
|
|
const root = detailDialogRef.current;
|
|
if (root) {
|
|
const focusables = focusableElements(root);
|
|
if (focusables.length > 0) {
|
|
focusables[0].focus();
|
|
} else {
|
|
root.focus();
|
|
}
|
|
}
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (!focusedJob || confirmOpenRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setFocusedJob(null);
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Tab") {
|
|
return;
|
|
}
|
|
|
|
const dialogRoot = detailDialogRef.current;
|
|
if (!dialogRoot) {
|
|
return;
|
|
}
|
|
|
|
const focusables = focusableElements(dialogRoot);
|
|
if (focusables.length === 0) {
|
|
event.preventDefault();
|
|
dialogRoot.focus();
|
|
return;
|
|
}
|
|
|
|
const first = focusables[0];
|
|
const last = focusables[focusables.length - 1];
|
|
const current = document.activeElement as HTMLElement | null;
|
|
|
|
if (event.shiftKey && current === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && current === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
if (detailLastFocusedRef.current) {
|
|
detailLastFocusedRef.current.focus();
|
|
}
|
|
};
|
|
}, [focusedJob]);
|
|
|
|
const toggleSelect = (id: number) => {
|
|
const newSet = new Set(selected);
|
|
if (newSet.has(id)) newSet.delete(id);
|
|
else newSet.add(id);
|
|
setSelected(newSet);
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selected.size === jobs.length && jobs.length > 0) {
|
|
setSelected(new Set());
|
|
} else {
|
|
setSelected(new Set(jobs.map(j => j.id)));
|
|
}
|
|
};
|
|
|
|
const selectedJobs = jobs.filter((job) => selected.has(job.id));
|
|
const hasSelectedActiveJobs = selectedJobs.some(isJobActive);
|
|
const activeCount = jobs.filter((job) => isJobActive(job)).length;
|
|
const failedCount = jobs.filter((job) => ["failed", "cancelled"].includes(job.status)).length;
|
|
const completedCount = jobs.filter((job) => job.status === "completed").length;
|
|
|
|
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
|
|
if (selected.size === 0) return;
|
|
setActionError(null);
|
|
|
|
try {
|
|
await apiAction("/api/jobs/batch", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
action,
|
|
ids: Array.from(selected)
|
|
})
|
|
});
|
|
setSelected(new Set());
|
|
showToast({
|
|
kind: "success",
|
|
title: "Jobs",
|
|
message: `${action[0].toUpperCase()}${action.slice(1)} request sent for selected jobs.`,
|
|
});
|
|
await fetchJobs();
|
|
} catch (e) {
|
|
const message = formatJobActionError(e, "Batch action failed");
|
|
setActionError(message);
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
}
|
|
};
|
|
|
|
const clearCompleted = async () => {
|
|
setActionError(null);
|
|
try {
|
|
const result = await apiJson<CountMessageResponse>("/api/jobs/clear-completed", {
|
|
method: "POST",
|
|
});
|
|
showToast({ kind: "success", title: "Jobs", message: result.message });
|
|
if (activeTab === "completed" && result.count > 0) {
|
|
showToast({
|
|
kind: "info",
|
|
title: "Jobs",
|
|
message: "Completed jobs archived. View them in the Archived tab.",
|
|
});
|
|
}
|
|
await fetchJobs();
|
|
} catch (e) {
|
|
const message = isApiError(e) ? e.message : "Failed to clear completed jobs";
|
|
setActionError(message);
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
}
|
|
};
|
|
|
|
const fetchJobDetails = async (id: number) => {
|
|
setActionError(null);
|
|
setDetailLoading(true);
|
|
try {
|
|
const data = await apiJson<JobDetail>(`/api/jobs/${id}/details`);
|
|
setFocusedJob(data);
|
|
} catch (e) {
|
|
const message = isApiError(e) ? e.message : "Failed to fetch job details";
|
|
setActionError(message);
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => {
|
|
setActionError(null);
|
|
try {
|
|
await apiAction(`/api/jobs/${id}/${action}`, { method: "POST" });
|
|
if (action === "delete") {
|
|
setFocusedJob((current) => (current?.job.id === id ? null : current));
|
|
} else if (focusedJob?.job.id === id) {
|
|
await fetchJobDetails(id);
|
|
}
|
|
await fetchJobs();
|
|
showToast({
|
|
kind: "success",
|
|
title: "Jobs",
|
|
message: `Job ${action} request completed.`,
|
|
});
|
|
} catch (e) {
|
|
const message = formatJobActionError(e, `Job ${action} failed`);
|
|
setActionError(message);
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
}
|
|
};
|
|
|
|
const handlePriority = async (job: Job, priority: number, label: string) => {
|
|
setActionError(null);
|
|
try {
|
|
await apiAction(`/api/jobs/${job.id}/priority`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ priority }),
|
|
});
|
|
if (focusedJob?.job.id === job.id) {
|
|
setFocusedJob({
|
|
...focusedJob,
|
|
job: {
|
|
...focusedJob.job,
|
|
priority,
|
|
},
|
|
});
|
|
}
|
|
await fetchJobs();
|
|
showToast({ kind: "success", title: "Jobs", message: `${label} for job #${job.id}.` });
|
|
} catch (e) {
|
|
const message = formatJobActionError(e, "Failed to update priority");
|
|
setActionError(message);
|
|
showToast({ kind: "error", title: "Jobs", message });
|
|
}
|
|
};
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
};
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return [h, m, s].map(v => v.toString().padStart(2, "0")).join(":");
|
|
};
|
|
|
|
const calcEta = (jobId: number, progress: number): string | null => {
|
|
if (progress <= 0 || progress >= 100) {
|
|
return null;
|
|
}
|
|
|
|
const startMs = encodeStartTimes.current.get(jobId);
|
|
if (!startMs) {
|
|
return null;
|
|
}
|
|
|
|
const elapsedMs = Date.now() - startMs;
|
|
const totalMs = elapsedMs / (progress / 100);
|
|
const remainingMs = totalMs - elapsedMs;
|
|
const remainingSecs = Math.round(remainingMs / 1000);
|
|
|
|
if (remainingSecs < 0) {
|
|
return null;
|
|
}
|
|
if (remainingSecs < 60) {
|
|
return `~${remainingSecs}s remaining`;
|
|
}
|
|
|
|
const mins = Math.ceil(remainingSecs / 60);
|
|
return `~${mins} min remaining`;
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const styles: Record<string, string> = {
|
|
queued: "bg-helios-slate/10 text-helios-slate border-helios-slate/20",
|
|
analyzing: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
|
encoding: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse",
|
|
remuxing: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse",
|
|
completed: "bg-green-500/10 text-green-500 border-green-500/20",
|
|
failed: "bg-red-500/10 text-red-500 border-red-500/20",
|
|
cancelled: "bg-red-500/10 text-red-500 border-red-500/20",
|
|
skipped: "bg-gray-500/10 text-gray-500 border-gray-500/20",
|
|
archived: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
|
|
resuming: "bg-helios-solar/10 text-helios-solar border-helios-solar/20 animate-pulse",
|
|
};
|
|
return (
|
|
<span className={cn("px-2.5 py-1 rounded-md text-xs font-medium border capitalize", styles[status] || styles.queued)}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const openConfirm = (config: {
|
|
title: string;
|
|
body: string;
|
|
confirmLabel: string;
|
|
confirmTone?: "danger" | "primary";
|
|
onConfirm: () => Promise<void> | void;
|
|
}) => {
|
|
setConfirmState(config);
|
|
};
|
|
|
|
const focusedDecision = focusedJob?.job.decision_reason
|
|
? humanizeSkipReason(focusedJob.job.decision_reason)
|
|
: null;
|
|
const focusedJobLogs = focusedJob?.job_logs ?? [];
|
|
const shouldShowFfmpegOutput = focusedJob
|
|
? ["failed", "completed", "skipped"].includes(focusedJob.job.status) && focusedJobLogs.length > 0
|
|
: false;
|
|
const completedEncodeStats = focusedJob?.job.status === "completed"
|
|
? focusedJob.encode_stats
|
|
: null;
|
|
|
|
return (
|
|
<div className="space-y-6 relative">
|
|
<div className="flex items-center gap-4 px-1 text-xs text-helios-slate">
|
|
<span>
|
|
<span className="font-medium text-helios-ink">
|
|
{activeCount}
|
|
</span>
|
|
{" "}active
|
|
</span>
|
|
<span>
|
|
<span className="font-medium text-red-500">
|
|
{failedCount}
|
|
</span>
|
|
{" "}failed
|
|
</span>
|
|
<span>
|
|
<span className="font-medium text-emerald-500">
|
|
{completedCount}
|
|
</span>
|
|
{" "}completed
|
|
</span>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col md:flex-row gap-4 justify-between items-center bg-helios-surface/50 p-1 rounded-lg border border-helios-line/10">
|
|
<div className="flex gap-1 p-1 bg-helios-surface border border-helios-line/10 rounded-lg">
|
|
{(["all", "active", "queued", "completed", "failed", "skipped", "archived"] as TabType[]).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => { setActiveTab(tab); setPage(1); }}
|
|
className={cn(
|
|
"px-4 py-1.5 rounded-md text-sm font-medium transition-all capitalize",
|
|
activeTab === tab
|
|
? "bg-helios-surface-soft text-helios-ink shadow-sm"
|
|
: "text-helios-slate hover:text-helios-ink"
|
|
)}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto">
|
|
<div className="relative flex-1 md:w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate" size={14} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search files..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => {
|
|
setSortBy(e.target.value as SortField);
|
|
setPage(1);
|
|
}}
|
|
className="h-10 rounded-lg border border-helios-line/20 bg-helios-surface px-3 text-sm text-helios-ink outline-none focus:border-helios-solar"
|
|
>
|
|
{SORT_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => {
|
|
setSortDesc((current) => !current);
|
|
setPage(1);
|
|
}}
|
|
className="flex h-10 w-10 items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface text-helios-ink hover:bg-helios-surface-soft"
|
|
title={sortDesc ? "Sort descending" : "Sort ascending"}
|
|
aria-label={sortDesc ? "Sort descending" : "Sort ascending"}
|
|
>
|
|
{sortDesc ? <ArrowDown size={16} /> : <ArrowUp size={16} />}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => void fetchJobs()}
|
|
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")}
|
|
>
|
|
<RefreshCw size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{actionError && (
|
|
<div role="alert" aria-live="polite" className="rounded-lg border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch Actions Bar */}
|
|
{selected.size > 0 && (
|
|
<div className="flex items-center justify-between bg-helios-solar/10 border border-helios-solar/20 px-6 py-3 rounded-lg animate-in fade-in slide-in-from-top-2">
|
|
<div>
|
|
<span className="text-sm font-bold text-helios-solar">
|
|
{selected.size} jobs selected
|
|
</span>
|
|
{hasSelectedActiveJobs && (
|
|
<p className="text-xs text-helios-slate mt-1">
|
|
Active jobs must be cancelled before they can be restarted or deleted.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Restart jobs",
|
|
body: `Restart ${selected.size} selected jobs?`,
|
|
confirmLabel: "Restart",
|
|
onConfirm: () => handleBatch("restart"),
|
|
})
|
|
}
|
|
disabled={hasSelectedActiveJobs}
|
|
className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar disabled:opacity-40 disabled:hover:bg-transparent"
|
|
title="Restart"
|
|
>
|
|
<RefreshCw size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Cancel jobs",
|
|
body: `Cancel ${selected.size} selected jobs?`,
|
|
confirmLabel: "Cancel",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleBatch("cancel"),
|
|
})
|
|
}
|
|
className="p-2 hover:bg-helios-solar/20 rounded-lg text-helios-solar"
|
|
title="Cancel"
|
|
>
|
|
<Ban size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Delete jobs",
|
|
body: `Delete ${selected.size} selected jobs from history?`,
|
|
confirmLabel: "Delete",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleBatch("delete"),
|
|
})
|
|
}
|
|
disabled={hasSelectedActiveJobs}
|
|
className="p-2 hover:bg-red-500/10 rounded-lg text-red-500 disabled:opacity-40 disabled:hover:bg-transparent"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="bg-helios-surface/50 border border-helios-line/20 rounded-lg overflow-hidden shadow-sm">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead className="bg-helios-surface border-b border-helios-line/20 text-xs font-medium text-helios-slate">
|
|
<tr>
|
|
<th className="px-6 py-4 w-10">
|
|
<input type="checkbox"
|
|
checked={jobs.length > 0 && jobs.every(j => selected.has(j.id))}
|
|
onChange={toggleSelectAll}
|
|
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
|
|
/>
|
|
</th>
|
|
<th className="px-6 py-4">File</th>
|
|
<th className="px-6 py-4">Status</th>
|
|
<th className="px-6 py-4">Progress</th>
|
|
<th className="hidden md:table-cell px-6 py-4">Updated</th>
|
|
<th className="px-6 py-4 w-14"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-helios-line/10">
|
|
{loading && jobs.length === 0 ? (
|
|
Array.from({ length: 5 }).map((_, index) => (
|
|
<tr key={`loading-${index}`}>
|
|
<td colSpan={6} className="px-6 py-3">
|
|
<div className="h-10 w-full rounded-md bg-helios-surface-soft/60 animate-pulse" />
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : jobs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-helios-slate">
|
|
No jobs found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
jobs.map((job) => (
|
|
<tr
|
|
key={job.id}
|
|
onClick={() => void fetchJobDetails(job.id)}
|
|
className={cn(
|
|
"group hover:bg-helios-surface/80 transition-all cursor-pointer",
|
|
selected.has(job.id) && "bg-helios-surface-soft",
|
|
focusedJob?.job.id === job.id && "bg-helios-solar/5"
|
|
)}
|
|
>
|
|
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
|
<input type="checkbox"
|
|
checked={selected.has(job.id)}
|
|
onChange={() => toggleSelect(job.id)}
|
|
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 relative">
|
|
<motion.div layoutId={`job-name-${job.id}`} className="flex flex-col">
|
|
<span className="font-medium text-helios-ink truncate max-w-[300px]" title={job.input_path}>
|
|
{job.input_path.split(/[/\\]/).pop()}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-helios-slate truncate max-w-[240px]">
|
|
{job.input_path}
|
|
</span>
|
|
<span className="hidden md:inline rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
|
|
P{job.priority}
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<motion.div layoutId={`job-status-${job.id}`}>
|
|
{getStatusBadge(job.status)}
|
|
</motion.div>
|
|
{job.status === "failed" && (() => {
|
|
// Reference tick so React re-renders countdowns on interval
|
|
void tick;
|
|
const countdown = retryCountdown(job);
|
|
return countdown ? (
|
|
<p className="text-[10px] font-mono text-helios-slate mt-0.5">
|
|
{countdown}
|
|
</p>
|
|
) : null;
|
|
})()}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{["encoding", "analyzing", "remuxing"].includes(job.status) ? (
|
|
<div className="w-24 space-y-1">
|
|
<div className="h-1.5 w-full bg-helios-line/10 rounded-full overflow-hidden">
|
|
<div className="h-full bg-helios-solar rounded-full transition-all duration-500" style={{ width: `${job.progress}%` }} />
|
|
</div>
|
|
<div className="text-xs text-right font-mono text-helios-slate">
|
|
{job.progress.toFixed(1)}%
|
|
</div>
|
|
{job.status === "encoding" && (() => {
|
|
const eta = calcEta(job.id, job.progress);
|
|
return eta ? (
|
|
<p className="text-[10px] text-helios-slate mt-0.5 font-mono">
|
|
{eta}
|
|
</p>
|
|
) : null;
|
|
})()}
|
|
{job.status === "encoding" && job.encoder && (
|
|
<span className="text-[10px] font-mono text-helios-solar opacity-70">
|
|
{job.encoder}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
job.vmaf_score ? (
|
|
<span className="text-xs font-mono text-helios-slate">
|
|
VMAF: {job.vmaf_score.toFixed(1)}
|
|
</span>
|
|
) : (
|
|
<span className="text-helios-slate/50">-</span>
|
|
)
|
|
)}
|
|
</td>
|
|
<td className="hidden md:table-cell px-6 py-4 text-xs text-helios-slate font-mono">
|
|
{new Date(job.updated_at).toLocaleString()}
|
|
</td>
|
|
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
|
<div className="relative" ref={menuJobId === job.id ? menuRef : null}>
|
|
<button
|
|
onClick={() => setMenuJobId(menuJobId === job.id ? null : job.id)}
|
|
className="p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft text-helios-slate"
|
|
title="Actions"
|
|
>
|
|
<MoreHorizontal size={14} />
|
|
</button>
|
|
<AnimatePresence>
|
|
{menuJobId === job.id && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 6 }}
|
|
className="absolute right-0 mt-2 w-44 rounded-lg border border-helios-line/20 bg-helios-surface shadow-xl z-20 overflow-hidden"
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
void fetchJobDetails(job.id);
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
View details
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
void handlePriority(job, job.priority + 10, "Priority boosted");
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
Boost priority (+10)
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
void handlePriority(job, job.priority - 10, "Priority lowered");
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
Lower priority (-10)
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
void handlePriority(job, 0, "Priority reset");
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
Reset priority
|
|
</button>
|
|
{(job.status === "failed" || job.status === "cancelled") && (
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
openConfirm({
|
|
title: "Retry job",
|
|
body: "Retry this job now?",
|
|
confirmLabel: "Retry",
|
|
onConfirm: () => handleAction(job.id, "restart"),
|
|
});
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
Retry
|
|
</button>
|
|
)}
|
|
{["encoding", "analyzing", "remuxing"].includes(job.status) && (
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
openConfirm({
|
|
title: "Cancel job",
|
|
body: "Stop this job immediately?",
|
|
confirmLabel: "Cancel",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleAction(job.id, "cancel"),
|
|
});
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-helios-ink hover:bg-helios-surface-soft"
|
|
>
|
|
Stop / Cancel
|
|
</button>
|
|
)}
|
|
{!isJobActive(job) && (
|
|
<button
|
|
onClick={() => {
|
|
setMenuJobId(null);
|
|
openConfirm({
|
|
title: "Delete job",
|
|
body: "Delete this job from history?",
|
|
confirmLabel: "Delete",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleAction(job.id, "delete"),
|
|
});
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-xs font-semibold text-red-500 hover:bg-red-500/5"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer Actions */}
|
|
<div className="flex justify-between items-center pt-2">
|
|
<p className="text-xs text-helios-slate font-medium">Showing {jobs.length} jobs (Limit 50)</p>
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Clear completed jobs",
|
|
body: "Remove all completed jobs from history?",
|
|
confirmLabel: "Clear",
|
|
confirmTone: "danger",
|
|
onConfirm: () => clearCompleted(),
|
|
})
|
|
}
|
|
className="text-xs text-red-500 hover:text-red-400 font-bold flex items-center gap-1 transition-colors"
|
|
>
|
|
<Trash2 size={12} /> Clear Completed
|
|
</button>
|
|
</div>
|
|
|
|
{/* Detail Overlay - rendered via portal to escape layout constraints */}
|
|
{typeof document !== "undefined" && createPortal(
|
|
<AnimatePresence>
|
|
{focusedJob && (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setFocusedJob(null)}
|
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
|
/>
|
|
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[101]">
|
|
<motion.div
|
|
key="modal-content"
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
ref={detailDialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="job-details-title"
|
|
aria-describedby="job-details-path"
|
|
tabIndex={-1}
|
|
className="w-full max-w-2xl bg-helios-surface border border-helios-line/20 rounded-lg shadow-2xl pointer-events-auto overflow-hidden mx-4"
|
|
>
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-helios-line/10 flex justify-between items-start gap-4 bg-helios-surface-soft/50">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
{getStatusBadge(focusedJob.job.status)}
|
|
<span className="text-xs font-medium text-helios-slate">Job ID #{focusedJob.job.id}</span>
|
|
<span className="text-xs font-medium text-helios-slate">Priority {focusedJob.job.priority}</span>
|
|
</div>
|
|
<h2 id="job-details-title" className="text-lg font-bold text-helios-ink truncate" title={focusedJob.job.input_path}>
|
|
{focusedJob.job.input_path.split(/[/\\]/).pop()}
|
|
</h2>
|
|
<p id="job-details-path" className="text-xs text-helios-slate truncate opacity-60">{focusedJob.job.input_path}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setFocusedJob(null)}
|
|
className="p-2 hover:bg-helios-line/10 rounded-md transition-colors text-helios-slate"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
|
{detailLoading && (
|
|
<p className="text-xs text-helios-slate" aria-live="polite">Loading job details...</p>
|
|
)}
|
|
{focusedJob.metadata || completedEncodeStats ? (
|
|
<>
|
|
{focusedJob.metadata && (
|
|
<>
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
|
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
|
<Activity size={12} />
|
|
<span className="text-xs font-medium text-helios-slate">Video Codec</span>
|
|
</div>
|
|
<p className="text-sm font-bold text-helios-ink capitalize">
|
|
{focusedJob.metadata.codec_name || "Unknown"}
|
|
</p>
|
|
<p className="text-xs text-helios-slate">
|
|
{(focusedJob.metadata.bit_depth ? `${focusedJob.metadata.bit_depth}-bit` : "Unknown bit depth")} • {focusedJob.metadata.container.toUpperCase()}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
|
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
|
<Maximize2 size={12} />
|
|
<span className="text-xs font-medium text-helios-slate">Resolution</span>
|
|
</div>
|
|
<p className="text-sm font-bold text-helios-ink">
|
|
{`${focusedJob.metadata.width}x${focusedJob.metadata.height}`}
|
|
</p>
|
|
<p className="text-xs text-helios-slate">
|
|
{focusedJob.metadata.fps.toFixed(2)} FPS
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-1">
|
|
<div className="flex items-center gap-2 text-helios-slate mb-1">
|
|
<Clock size={12} />
|
|
<span className="text-xs font-medium text-helios-slate">Duration</span>
|
|
</div>
|
|
<p className="text-sm font-bold text-helios-ink">
|
|
{formatDuration(focusedJob.metadata.duration_secs)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Media Details */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
<div className="space-y-4">
|
|
<h3 className="text-xs font-medium text-helios-slate/70 flex items-center gap-2">
|
|
<Database size={12} /> Input Details
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">File Size</span>
|
|
<span className="text-helios-ink font-bold">{formatBytes(focusedJob.metadata.size_bytes)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Video Bitrate</span>
|
|
<span className="text-helios-ink font-bold">
|
|
{(focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps)
|
|
? `${(((focusedJob.metadata.video_bitrate_bps ?? focusedJob.metadata.container_bitrate_bps) as number) / 1000).toFixed(0)} kbps`
|
|
: "-"}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Audio</span>
|
|
<span className="text-helios-ink font-bold capitalize">
|
|
{focusedJob.metadata.audio_codec || "N/A"} ({focusedJob.metadata.audio_channels || 0}ch)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<h3 className="text-xs font-medium text-helios-solar flex items-center gap-2">
|
|
<Zap size={12} /> Output Details
|
|
</h3>
|
|
{focusedJob.encode_stats ? (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Result Size</span>
|
|
<span className="text-helios-solar font-bold">{formatBytes(focusedJob.encode_stats.output_size_bytes)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Reduction</span>
|
|
<span className="text-green-500 font-bold">
|
|
{((1 - focusedJob.encode_stats.compression_ratio) * 100).toFixed(1)}% Saved
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">VMAF Score</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="h-1.5 w-16 bg-helios-line/10 rounded-full overflow-hidden">
|
|
<div className="h-full bg-helios-solar" style={{ width: `${focusedJob.encode_stats.vmaf_score || 0}%` }} />
|
|
</div>
|
|
<span className="text-helios-ink font-bold">
|
|
{focusedJob.encode_stats.vmaf_score?.toFixed(1) || "-"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-[80px] flex items-center justify-center border border-dashed border-helios-line/20 rounded-lg text-xs text-helios-slate italic">
|
|
{focusedJob.job.status === "encoding"
|
|
? "Encoding in progress..."
|
|
: focusedJob.job.status === "remuxing"
|
|
? "Remuxing in progress..."
|
|
: "No encode data available"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{completedEncodeStats && (
|
|
<div className="space-y-4">
|
|
<h3 className="text-xs font-medium text-helios-solar flex items-center gap-2">
|
|
<Zap size={12} /> Encode Results
|
|
</h3>
|
|
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/20 space-y-3">
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Input size</span>
|
|
<span className="text-helios-ink font-bold">{formatBytes(completedEncodeStats.input_size_bytes)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Output size</span>
|
|
<span className="text-helios-ink font-bold">{formatBytes(completedEncodeStats.output_size_bytes)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Reduction</span>
|
|
<span className="text-green-500 font-bold">
|
|
{completedEncodeStats.input_size_bytes > 0
|
|
? `${((1 - completedEncodeStats.output_size_bytes / completedEncodeStats.input_size_bytes) * 100).toFixed(1)}% saved`
|
|
: "—"}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Encode time</span>
|
|
<span className="text-helios-ink font-bold">{formatDuration(completedEncodeStats.encode_time_seconds)}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Speed</span>
|
|
<span className="text-helios-ink font-bold">{`${completedEncodeStats.encode_speed.toFixed(2)}\u00d7 realtime`}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">Avg bitrate</span>
|
|
<span className="text-helios-ink font-bold">{`${completedEncodeStats.avg_bitrate_kbps} kbps`}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-helios-slate font-medium">VMAF</span>
|
|
<span className="text-helios-ink font-bold">{completedEncodeStats.vmaf_score?.toFixed(1) ?? "—"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center gap-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-5">
|
|
<div className="p-2 rounded-lg bg-helios-surface border border-helios-line/20 text-helios-slate shrink-0">
|
|
<Clock size={18} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-helios-ink">
|
|
Waiting for analysis
|
|
</p>
|
|
<p className="text-xs text-helios-slate mt-0.5">
|
|
Metadata will appear once this job is picked up by the engine.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Decision Info */}
|
|
{focusedJob.job.decision_reason && focusedJob.job.status !== "failed" && focusedJob.job.status !== "skipped" && (
|
|
<div className="p-4 rounded-lg bg-helios-solar/5 border border-helios-solar/10">
|
|
<div className="flex items-center gap-2 text-helios-solar mb-1">
|
|
<Info size={12} />
|
|
<span className="text-xs font-medium text-helios-slate">Decision Context</span>
|
|
</div>
|
|
{focusedDecision && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium text-helios-ink">
|
|
{focusedJob.job.status === "completed"
|
|
? "Transcoded"
|
|
: focusedDecision.summary}
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-helios-slate">
|
|
{focusedDecision.detail}
|
|
</p>
|
|
{Object.keys(focusedDecision.measured).length > 0 && (
|
|
<div className="space-y-1.5 rounded-lg border border-helios-line/20 bg-helios-surface-soft px-3 py-2.5">
|
|
{Object.entries(focusedDecision.measured).map(([k, v]) => (
|
|
<div key={k} className="flex items-center justify-between text-xs">
|
|
<span className="font-mono text-helios-slate">{k}</span>
|
|
<span className="font-mono font-bold text-helios-ink">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{focusedDecision.action && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-helios-solar/20 bg-helios-solar/5 px-3 py-2.5">
|
|
<span className="text-xs leading-relaxed text-helios-solar">
|
|
{focusedDecision.action}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{focusedJob.job.status === "skipped" && focusedJob.job.decision_reason && (
|
|
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/10">
|
|
<p className="text-sm text-helios-ink leading-relaxed">
|
|
Alchemist analysed this file and decided not to transcode it. Here's why:
|
|
</p>
|
|
{focusedDecision && (
|
|
<div className="mt-3 space-y-3">
|
|
<p className="text-sm font-medium text-helios-ink">
|
|
{focusedDecision.summary}
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-helios-slate">
|
|
{focusedDecision.detail}
|
|
</p>
|
|
{Object.keys(focusedDecision.measured).length > 0 && (
|
|
<div className="space-y-1.5 rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2.5">
|
|
{Object.entries(focusedDecision.measured).map(([k, v]) => (
|
|
<div key={k} className="flex items-center justify-between text-xs">
|
|
<span className="font-mono text-helios-slate">{k}</span>
|
|
<span className="font-mono font-bold text-helios-ink">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{focusedDecision.action && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-helios-solar/20 bg-helios-solar/5 px-3 py-2.5">
|
|
<span className="text-xs leading-relaxed text-helios-solar">
|
|
{focusedDecision.action}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{focusedJob.job.status === "failed" && (
|
|
<div className="rounded-lg border border-status-error/20 bg-status-error/5 px-4 py-4 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle size={14} className="text-status-error shrink-0" />
|
|
<span className="text-xs font-semibold text-status-error uppercase tracking-wide">
|
|
Failure Reason
|
|
</span>
|
|
</div>
|
|
{focusedJob.job_failure_summary ? (
|
|
<>
|
|
<p className="text-sm font-medium text-helios-ink">
|
|
{explainFailureSummary(focusedJob.job_failure_summary)}
|
|
</p>
|
|
<p className="text-xs font-mono text-helios-slate/70 break-all leading-relaxed">
|
|
{focusedJob.job_failure_summary}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-helios-slate">
|
|
No error details captured. Check the logs below.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{shouldShowFfmpegOutput && (
|
|
<details className="rounded-lg border border-helios-line/15 bg-helios-surface-soft/40 p-4">
|
|
<summary className="cursor-pointer text-xs text-helios-solar">
|
|
Show FFmpeg output ({focusedJobLogs.length} lines)
|
|
</summary>
|
|
<div className="mt-3 max-h-48 overflow-y-auto rounded-lg bg-helios-main/70 p-3">
|
|
{focusedJobLogs.map((entry) => (
|
|
<div
|
|
key={entry.id}
|
|
className={cn(
|
|
"font-mono text-xs leading-relaxed whitespace-pre-wrap break-words",
|
|
logLevelClass(entry.level)
|
|
)}
|
|
>
|
|
{entry.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
{/* Action Toolbar */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-helios-line/10">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => void handlePriority(focusedJob.job, focusedJob.job.priority + 10, "Priority boosted")}
|
|
className="px-3 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold hover:bg-helios-surface-soft transition-all"
|
|
>
|
|
Boost +10
|
|
</button>
|
|
<button
|
|
onClick={() => void handlePriority(focusedJob.job, focusedJob.job.priority - 10, "Priority lowered")}
|
|
className="px-3 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold hover:bg-helios-surface-soft transition-all"
|
|
>
|
|
Lower -10
|
|
</button>
|
|
<button
|
|
onClick={() => void handlePriority(focusedJob.job, 0, "Priority reset")}
|
|
className="px-3 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold hover:bg-helios-surface-soft transition-all"
|
|
>
|
|
Reset
|
|
</button>
|
|
{(focusedJob.job.status === 'failed' || focusedJob.job.status === 'cancelled') && (
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Retry job",
|
|
body: "Retry this job now?",
|
|
confirmLabel: "Retry",
|
|
onConfirm: () => handleAction(focusedJob.job.id, 'restart'),
|
|
})
|
|
}
|
|
className="px-4 py-2 bg-helios-solar text-helios-main rounded-lg text-sm font-bold flex items-center gap-2 hover:brightness-110 active:scale-95 transition-all shadow-sm"
|
|
>
|
|
<RefreshCw size={14} /> Retry Job
|
|
</button>
|
|
)}
|
|
{["encoding", "analyzing", "remuxing"].includes(focusedJob.job.status) && (
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Cancel job",
|
|
body: "Stop this job immediately?",
|
|
confirmLabel: "Cancel",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleAction(focusedJob.job.id, 'cancel'),
|
|
})
|
|
}
|
|
className="px-4 py-2 border border-helios-line/20 bg-helios-surface text-helios-slate rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-helios-surface-soft active:scale-95 transition-all"
|
|
>
|
|
<Ban size={14} /> Stop / Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!isJobActive(focusedJob.job) && (
|
|
<button
|
|
onClick={() =>
|
|
openConfirm({
|
|
title: "Delete job",
|
|
body: "Delete this job from history?",
|
|
confirmLabel: "Delete",
|
|
confirmTone: "danger",
|
|
onConfirm: () => handleAction(focusedJob.job.id, 'delete'),
|
|
})
|
|
}
|
|
className="px-4 py-2 text-red-500 hover:bg-red-500/5 rounded-lg text-sm font-bold flex items-center gap-2 transition-all"
|
|
>
|
|
<Trash2 size={14} /> Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>,
|
|
document.body
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirmState !== null}
|
|
title={confirmState?.title ?? ""}
|
|
description={confirmState?.body ?? ""}
|
|
confirmLabel={confirmState?.confirmLabel ?? "Confirm"}
|
|
tone={confirmState?.confirmTone ?? "primary"}
|
|
onClose={() => setConfirmState(null)}
|
|
onConfirm={async () => {
|
|
if (!confirmState) {
|
|
return;
|
|
}
|
|
await confirmState.onConfirm();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default withErrorBoundary(JobManager, "Job Management");
|