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:
Jaret Burkett
2026-06-19 08:00:40 -06:00
parent 99f8fd44e3
commit 548a286992
6 changed files with 184 additions and 6 deletions

View File

@@ -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}

View File

@@ -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">

View 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>
);
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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}