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 useSettings from '@/hooks/useSettings';
|
||||||
import { pathJoin } from '@/utils/basic';
|
import { pathJoin } from '@/utils/basic';
|
||||||
import AutoCaptionButton from '@/components/AutoCaptionButton';
|
import AutoCaptionButton from '@/components/AutoCaptionButton';
|
||||||
|
import CaptionMonitor from '@/components/CaptionMonitor';
|
||||||
import { CreatableSelectInput } from '@/components/formInputs';
|
import { CreatableSelectInput } from '@/components/formInputs';
|
||||||
|
|
||||||
export default function DatasetPage({ params }: { params: { datasetName: string } }) {
|
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 [captionExt, setCaptionExt] = useState<string>('txt');
|
||||||
const [captionRefreshKeys, setCaptionRefreshKeys] = useState<Record<string, number>>({});
|
const [captionRefreshKeys, setCaptionRefreshKeys] = useState<Record<string, number>>({});
|
||||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [captionBarHeight, setCaptionBarHeight] = useState(0);
|
||||||
const scrollParentCallback = useCallback((el: HTMLDivElement | null) => setScrollParent(el), []);
|
const scrollParentCallback = useCallback((el: HTMLDivElement | null) => setScrollParent(el), []);
|
||||||
|
|
||||||
const refreshImageList = (dbName: string) => {
|
const refreshImageList = (dbName: string) => {
|
||||||
@@ -174,8 +176,17 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
|||||||
computeItemKey={index => imgList[index]?.img_path ?? index}
|
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>
|
</MainContent>
|
||||||
<AddImagesModal />
|
<AddImagesModal />
|
||||||
|
{isSettingsLoaded && (
|
||||||
|
<CaptionMonitor
|
||||||
|
datasetPath={`${pathJoin(settings.DATASETS_FOLDER, datasetName)}`}
|
||||||
|
onHeightChange={setCaptionBarHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DatasetImageViewer
|
<DatasetImageViewer
|
||||||
imgPath={selectedImgPath}
|
imgPath={selectedImgPath}
|
||||||
imageList={imgPaths}
|
imageList={imgPaths}
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export default function ActiveJobWidget() {
|
|||||||
const isRunning = job.status === 'running' || job.status === 'stopping';
|
const isRunning = job.status === 'running' || job.status === 'stopping';
|
||||||
|
|
||||||
let label = job.name;
|
let label = job.name;
|
||||||
|
let href = `/jobs/${job.id}`;
|
||||||
if (job.job_type === 'caption') {
|
if (job.job_type === 'caption') {
|
||||||
const splits = (job.job_ref ?? '').split(/[/\\]/);
|
const splits = (job.job_ref ?? '').split(/[/\\]/);
|
||||||
label = splits[splits.length - 1] || job.name;
|
label = splits[splits.length - 1] || job.name;
|
||||||
|
href = `/datasets/${label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let statusColor = 'text-gray-400';
|
let statusColor = 'text-gray-400';
|
||||||
@@ -38,7 +40,7 @@ export default function ActiveJobWidget() {
|
|||||||
return (
|
return (
|
||||||
<li key={job.id} className="min-w-0">
|
<li key={job.id} className="min-w-0">
|
||||||
<Link
|
<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"
|
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">
|
<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;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<Dialog open={isOpen} onClose={onCancel} className="relative z-10">
|
<Dialog open={isOpen} onClose={onCancel} className="relative z-50">
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
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"
|
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">
|
<div className="flex min-h-full items-center justify-center p-0 sm:p-4 text-center">
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface JobActionBarProps {
|
|||||||
hideView?: boolean;
|
hideView?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
autoStartQueue?: boolean;
|
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({
|
export default function JobActionBar({
|
||||||
@@ -25,6 +28,7 @@ export default function JobActionBar({
|
|||||||
className,
|
className,
|
||||||
hideView,
|
hideView,
|
||||||
autoStartQueue = false,
|
autoStartQueue = false,
|
||||||
|
menuAnchor = 'bottom',
|
||||||
}: JobActionBarProps) {
|
}: JobActionBarProps) {
|
||||||
const { canStart, canStop, canDelete, canEdit, canRemoveFromQueue } = getAvaliableJobActions(job);
|
const { canStart, canStop, canDelete, canEdit, canRemoveFromQueue } = getAvaliableJobActions(job);
|
||||||
|
|
||||||
@@ -140,7 +144,10 @@ export default function JobActionBar({
|
|||||||
<MenuButton className={'ml-1 sm:ml-2'}>
|
<MenuButton className={'ml-1 sm:ml-2'}>
|
||||||
<Cog className={iconSizeClass} />
|
<Cog className={iconSizeClass} />
|
||||||
</MenuButton>
|
</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' && (
|
{job.job_type === 'train' && (
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
<Link
|
<Link
|
||||||
@@ -152,7 +159,7 @@ export default function JobActionBar({
|
|||||||
</Link>
|
</Link>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{canStop && (
|
{job.job_type === 'train' && canStop && (
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded flex items-center gap-2"
|
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',
|
key: 'name',
|
||||||
render: row => {
|
render: row => {
|
||||||
let title = row.name;
|
let title = row.name;
|
||||||
|
let href = `/jobs/${row.id}`;
|
||||||
// if (row.job_type === 'train') title = `Train: ${title}`;
|
// if (row.job_type === 'train') title = `Train: ${title}`;
|
||||||
if (row.job_type === 'caption') {
|
if (row.job_type === 'caption') {
|
||||||
let splits = row.job_ref.split(/[/\\]/);
|
let splits = row.job_ref.split(/[/\\]/);
|
||||||
const datasetPath = `${splits[splits.length - 1]}`;
|
const datasetPath = `${splits[splits.length - 1]}`;
|
||||||
|
href = `/datasets/${datasetPath}`;
|
||||||
title = (
|
title = (
|
||||||
<>
|
<>
|
||||||
<small className="opacity-50">CAPTION: </small> {datasetPath}
|
<small className="opacity-50">CAPTION: </small> {datasetPath}
|
||||||
@@ -137,7 +139,7 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link href={`/jobs/${row.id}`} className="font-medium whitespace-nowrap">
|
<Link href={href} className="font-medium whitespace-nowrap">
|
||||||
{['running', 'stopping'].includes(row.status) ? (
|
{['running', 'stopping'].includes(row.status) ? (
|
||||||
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
|
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user