Add captioning monitoring information to the dataset page so you can just stay on that page with all the info there.
This commit is contained in:
@@ -13,6 +13,7 @@ import { apiClient } from '@/utils/api';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { pathJoin } from '@/utils/basic';
|
||||
import AutoCaptionButton from '@/components/AutoCaptionButton';
|
||||
import CaptionMonitor from '@/components/CaptionMonitor';
|
||||
import { CreatableSelectInput } from '@/components/formInputs';
|
||||
|
||||
export default function DatasetPage({ params }: { params: { datasetName: string } }) {
|
||||
@@ -26,6 +27,7 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
||||
const [captionExt, setCaptionExt] = useState<string>('txt');
|
||||
const [captionRefreshKeys, setCaptionRefreshKeys] = useState<Record<string, number>>({});
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const [captionBarHeight, setCaptionBarHeight] = useState(0);
|
||||
const scrollParentCallback = useCallback((el: HTMLDivElement | null) => setScrollParent(el), []);
|
||||
|
||||
const refreshImageList = (dbName: string) => {
|
||||
@@ -174,8 +176,17 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
||||
computeItemKey={index => imgList[index]?.img_path ?? index}
|
||||
/>
|
||||
)}
|
||||
{/* Spacer so the last cards stay accessible above the floating caption bar.
|
||||
Always keeps a baseline gap, plus the bar height when it is showing. */}
|
||||
<div style={{ height: `${captionBarHeight + 24}px` }} className="transition-[height] duration-300" />
|
||||
</MainContent>
|
||||
<AddImagesModal />
|
||||
{isSettingsLoaded && (
|
||||
<CaptionMonitor
|
||||
datasetPath={`${pathJoin(settings.DATASETS_FOLDER, datasetName)}`}
|
||||
onHeightChange={setCaptionBarHeight}
|
||||
/>
|
||||
)}
|
||||
<DatasetImageViewer
|
||||
imgPath={selectedImgPath}
|
||||
imageList={imgPaths}
|
||||
|
||||
@@ -25,9 +25,11 @@ export default function ActiveJobWidget() {
|
||||
const isRunning = job.status === 'running' || job.status === 'stopping';
|
||||
|
||||
let label = job.name;
|
||||
let href = `/jobs/${job.id}`;
|
||||
if (job.job_type === 'caption') {
|
||||
const splits = (job.job_ref ?? '').split(/[/\\]/);
|
||||
label = splits[splits.length - 1] || job.name;
|
||||
href = `/datasets/${label}`;
|
||||
}
|
||||
|
||||
let statusColor = 'text-gray-400';
|
||||
@@ -38,7 +40,7 @@ export default function ActiveJobWidget() {
|
||||
return (
|
||||
<li key={job.id} className="min-w-0">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
href={href}
|
||||
className="block px-3 py-2 bg-gray-800 hover:bg-gray-950 rounded-lg transition-colors min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
|
||||
156
ui/src/components/CaptionMonitor.tsx
Normal file
156
ui/src/components/CaptionMonitor.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Cpu } from 'lucide-react';
|
||||
import useJobByRef from '@/hooks/useJobByRef';
|
||||
import useJobLog from '@/hooks/useJobLog';
|
||||
import useGPUInfo from '@/hooks/useGPUInfo';
|
||||
import GPUWidget from '@/components/GPUWidget';
|
||||
import JobActionBar from '@/components/JobActionBar';
|
||||
import { getTotalSteps } from '@/utils/jobs';
|
||||
|
||||
interface CaptionMonitorProps {
|
||||
datasetPath: string;
|
||||
// Reports the current docked height (px) so the page can pad content to match.
|
||||
onHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
// A small floating module that pops up from the bottom while a dataset is being
|
||||
// auto-captioned. It is a simplified version of the job page: action bar, GPU
|
||||
// info, and the live log.
|
||||
export default function CaptionMonitor({ datasetPath, onHeightChange }: CaptionMonitorProps) {
|
||||
const { job, status, refreshJob } = useJobByRef(datasetPath, 3000);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const isActive = !!(job && (job.status === 'running' || job.status === 'queued'));
|
||||
|
||||
// Reset the collapsed state whenever a new captioning run starts.
|
||||
useEffect(() => {
|
||||
if (isActive) setCollapsed(false);
|
||||
}, [job?.id, isActive]);
|
||||
|
||||
const gpuIds = useMemo(() => {
|
||||
if (!job) return [];
|
||||
if (job.gpu_ids === 'mps') return [0];
|
||||
return job.gpu_ids.split(',').map(id => parseInt(id));
|
||||
}, [job?.gpu_ids]);
|
||||
|
||||
const { gpuList, isGPUInfoLoaded } = useGPUInfo(gpuIds, 5000);
|
||||
const { log, status: statusLog } = useJobLog(job?.id ?? '', isActive ? 2000 : null);
|
||||
|
||||
const totalSteps = job ? getTotalSteps(job) : 0;
|
||||
const progress = totalSteps > 0 ? (job!.step / totalSteps) * 100 : 0;
|
||||
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
|
||||
|
||||
const logLines: string[] = useMemo(() => {
|
||||
let splits: string[] = log.split(/\n|\r\n/);
|
||||
splits = splits.map(line => line.split(/\r/).pop()) as string[];
|
||||
const maxLines = 1000;
|
||||
if (splits.length > maxLines) {
|
||||
splits = splits.slice(splits.length - maxLines);
|
||||
}
|
||||
return splits;
|
||||
}, [log]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (logRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logRef.current;
|
||||
setIsScrolledToBottom(scrollHeight - scrollTop - clientHeight < 10);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (logRef.current && isScrolledToBottom) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}, [log, isScrolledToBottom]);
|
||||
|
||||
// Animate the docked height instead of translating, so the collapsed panel
|
||||
// never extends below the container and adds scroll. Inactive -> 0 (hidden),
|
||||
// collapsed -> just the header bar, expanded -> full panel.
|
||||
const HEADER_HEIGHT = 44;
|
||||
const PANEL_HEIGHT = 300;
|
||||
let height = 0;
|
||||
if (isActive) height = collapsed ? HEADER_HEIGHT : PANEL_HEIGHT;
|
||||
|
||||
useEffect(() => {
|
||||
onHeightChange?.(height);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full z-40 overflow-hidden transition-[height] duration-300"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 border-t border-gray-700 shadow-2xl flex flex-col"
|
||||
style={{ height: `${PANEL_HEIGHT}px` }}
|
||||
>
|
||||
{/* Header / action bar */}
|
||||
<div
|
||||
className="bg-gray-800 px-3 flex items-center gap-2 flex-shrink-0"
|
||||
style={{ height: `${HEADER_HEIGHT}px` }}
|
||||
>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-emerald-500/10 text-emerald-500 whitespace-nowrap">
|
||||
{job?.status ?? '...'}
|
||||
</span>
|
||||
<h2 className="text-sm text-gray-100 truncate min-w-0 flex-shrink">{job?.info || 'Captioning...'}</h2>
|
||||
{totalSteps > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-1 bg-gray-700 rounded-full h-2 min-w-0">
|
||||
<div className="h-2 rounded-full bg-blue-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-300 whitespace-nowrap">
|
||||
{job!.step} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{job && <JobActionBar job={job} onRefresh={refreshJob} autoStartQueue={true} menuAnchor="top end" />}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="text-gray-400 hover:text-gray-100"
|
||||
title={collapsed ? 'Show' : 'Hide'}
|
||||
>
|
||||
{collapsed ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: log + GPU info */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex-1 min-w-0 p-3">
|
||||
<div className="bg-gray-950 rounded-lg h-full relative">
|
||||
<div
|
||||
ref={logRef}
|
||||
className="text-xs text-gray-300 absolute inset-0 p-3 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{statusLog === 'loading' && 'Loading log...'}
|
||||
{statusLog === 'error' && 'Error loading log'}
|
||||
{['success', 'refreshing'].includes(statusLog) && (
|
||||
<div>
|
||||
{logLines.map((line, index) => (
|
||||
<pre key={index}>{line}</pre>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-96 flex-shrink-0 overflow-y-auto p-3 pl-0 hidden md:block">
|
||||
{isGPUInfoLoaded && gpuList.length > 0 ? (
|
||||
<GPUWidget gpu={gpuList[0]} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 p-2">
|
||||
<Cpu className="w-4 h-4" /> Loading GPU info...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -392,12 +392,12 @@ export default function DatasetImageViewer({
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<Dialog open={isOpen} onClose={onCancel} className="relative z-10">
|
||||
<Dialog open={isOpen} onClose={onCancel} className="relative z-50">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||
/>
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-0 sm:p-4 text-center">
|
||||
<DialogPanel
|
||||
transition
|
||||
|
||||
@@ -16,6 +16,9 @@ interface JobActionBarProps {
|
||||
hideView?: boolean;
|
||||
className?: string;
|
||||
autoStartQueue?: boolean;
|
||||
// Where the gear menu opens; defaults to below. Use 'top end' when the bar is
|
||||
// pinned to the bottom of the screen so the menu opens upward into view.
|
||||
menuAnchor?: 'bottom' | 'top end';
|
||||
}
|
||||
|
||||
export default function JobActionBar({
|
||||
@@ -25,6 +28,7 @@ export default function JobActionBar({
|
||||
className,
|
||||
hideView,
|
||||
autoStartQueue = false,
|
||||
menuAnchor = 'bottom',
|
||||
}: JobActionBarProps) {
|
||||
const { canStart, canStop, canDelete, canEdit, canRemoveFromQueue } = getAvaliableJobActions(job);
|
||||
|
||||
@@ -140,7 +144,10 @@ export default function JobActionBar({
|
||||
<MenuButton className={'ml-1 sm:ml-2'}>
|
||||
<Cog className={iconSizeClass} />
|
||||
</MenuButton>
|
||||
<MenuItems anchor="bottom" className="bg-gray-900 border border-gray-700 rounded shadow-lg w-52 px-2 py-2 mt-4">
|
||||
<MenuItems
|
||||
anchor={{ to: menuAnchor, gap: 16 }}
|
||||
className="bg-gray-900 border border-gray-700 rounded shadow-lg w-52 px-2 py-2 z-50"
|
||||
>
|
||||
{job.job_type === 'train' && (
|
||||
<MenuItem>
|
||||
<Link
|
||||
@@ -152,7 +159,7 @@ export default function JobActionBar({
|
||||
</Link>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canStop && (
|
||||
{job.job_type === 'train' && canStop && (
|
||||
<MenuItem>
|
||||
<div
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded flex items-center gap-2"
|
||||
|
||||
@@ -126,10 +126,12 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
|
||||
key: 'name',
|
||||
render: row => {
|
||||
let title = row.name;
|
||||
let href = `/jobs/${row.id}`;
|
||||
// if (row.job_type === 'train') title = `Train: ${title}`;
|
||||
if (row.job_type === 'caption') {
|
||||
let splits = row.job_ref.split(/[/\\]/);
|
||||
const datasetPath = `${splits[splits.length - 1]}`;
|
||||
href = `/datasets/${datasetPath}`;
|
||||
title = (
|
||||
<>
|
||||
<small className="opacity-50">CAPTION: </small> {datasetPath}
|
||||
@@ -137,7 +139,7 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={`/jobs/${row.id}`} className="font-medium whitespace-nowrap">
|
||||
<Link href={href} className="font-medium whitespace-nowrap">
|
||||
{['running', 'stopping'].includes(row.status) ? (
|
||||
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user