Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions studio/web/src/components/ImagePreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'

import ZoomableImage from './ZoomableImage'

Expand All @@ -23,6 +24,9 @@ interface Props {
onAccept?: () => void
onDelete?: () => void
shortcutHint?: string
/** URLs to warm up in the background (usually the prev/next image), so flipping
* with the arrows shows an already-decoded image instead of loading from scratch. */
preload?: string[]
}

export default function ImagePreviewModal({
Expand All @@ -41,9 +45,15 @@ export default function ImagePreviewModal({
onAccept,
onDelete,
shortcutHint,
preload,
}: Props) {
// Handlers live in a ref so the keydown listener binds once: callers pass fresh
// inline closures on every render (and some re-render on every training tick).
const keysRef = useRef({ hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete })
keysRef.current = { hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete }
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const { hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete } = keysRef.current
if (e.key === 'Escape') {
e.preventDefault()
onClose()
Expand All @@ -63,11 +73,35 @@ export default function ImagePreviewModal({
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete])
}, [])

// The page behind stays put while the lightbox is open (no scroll-through on wheel / touch).
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [])

// Warm up the neighbours; keyed by the joined URLs so a re-render with the same list is a no-op.
const preloadKey = (preload ?? []).filter(Boolean).join('\n')
useEffect(() => {
if (!preloadKey) return
const t = window.setTimeout(() => {
for (const u of preloadKey.split('\n')) {
const img = new Image()
img.decoding = 'async'
img.src = u
}
}, 150)
return () => window.clearTimeout(t)
}, [preloadKey])

const counter = index != null && total != null ? `${index + 1} / ${total}` : null

return (
// Portaled to <body>: callers mount this deep inside cards, and an ancestor with opacity /
// transform (e.g. a finished queue card at opacity .72) would otherwise make the fixed
// overlay translucent, trap it under later siblings and composite the page behind every frame.
return createPortal(
<div
className="fixed inset-0 z-50 bg-black flex flex-col"
onClick={onClose}
Expand Down Expand Up @@ -132,7 +166,8 @@ export default function ImagePreviewModal({
{shortcutHint && <div>{shortcutHint}</div>}
</div>
)}
</div>
</div>,
document.body,
)
}

Expand All @@ -152,6 +187,7 @@ function SplitPane({
src={src}
alt={label ?? altFallback ?? 'preview'}
onClick={(e) => e.stopPropagation()}
decoding="async"
className="max-w-full max-h-full object-contain"
/>
</div>
Expand Down
51 changes: 25 additions & 26 deletions studio/web/src/components/MonitorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Data source: GET /api/state?task_id=N fetches a downsampled snapshot + SSE monitor_progress
* goes through the useMonitorProgress hook for delta merging (PR #37's incremental protocol).
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { api, type EvalJobInfo, type EvalMetricResult, type EvalMetricState, type LoraCkpt, type MonitorState } from '../api/client'
import { evalProgressFromResults } from '../lib/useEvalProgress'
import { useMonitorProgress } from '../lib/useMonitorProgress'
Expand Down Expand Up @@ -840,7 +840,7 @@ function sampleMarks(s: { path: string; step?: number }): { step: number | null;
}
}

function SampleViewer({ samples, taskId }: {
const SampleViewer = memo(function SampleViewer({ samples, taskId }: {
samples: Array<{ path: string; step?: number }>
taskId: number
}) {
Expand Down Expand Up @@ -956,7 +956,7 @@ function SampleViewer({ samples, taskId }: {
key={fullUrl}
src={fullUrl}
alt="sample preview"
loading="lazy"
decoding="async"
onClick={() => setZoomOpen(true)}
className="absolute inset-0 w-full h-full object-contain cursor-zoom-in"
/>
Expand Down Expand Up @@ -985,11 +985,14 @@ function SampleViewer({ samples, taskId }: {
onClose={() => setZoomOpen(false)}
onPrev={() => setActive((i) => Math.max(0, i - 1))}
onNext={() => setActive((i) => Math.min(list.length - 1, i + 1))}
preload={[list[active - 1], list[active + 1]]
.filter((x): x is { path: string; step?: number } => !!x)
.map((x) => api.sampleImageUrl(x.path.split(/[\\/]/).pop() ?? x.path, taskId))}
/>
)}
</div>
)
}
})

// ── Main Component ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1056,6 +1059,15 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
const vramTotal = state?.vram_total_gb
const vramTone = vram && vramTotal ? (vram / vramTotal > 0.85 ? 'warn' : 'ok') as 'ok' | 'warn' : undefined

// Full raw series (no more slice(-60)) -- SeriesChart downsamples evenly to 600 points internally when rendering.
// Memoized: the arrays run up to 50k points and the dashboard re-renders on every SSE delta, so the
// charts only rebuild when their own series actually changed.
const lossSeries = useMemo(() => losses.map((l) => ({ step: l.step, value: l.loss })), [losses])
const lrSeries = useMemo(() => lrHistory.map((l) => ({ step: l.step, value: l.lr })), [lrHistory])
const dSeries = useMemo(() => optimizerMetricsHistory
.filter((m) => typeof m.d === 'number')
.map((m) => ({ step: m.step, value: m.d as number })), [optimizerMetricsHistory])

if (!state && !connected) {
return (
<div className="grid place-items-center h-[200px] text-fg-tertiary text-sm">
Expand All @@ -1064,13 +1076,6 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
)
}

// Full raw series (no more slice(-60)) -- SeriesChart downsamples evenly to 600 points internally when rendering
const lrSeries = lrHistory.map((l) => ({ step: l.step, value: l.lr }))
const dSeries = optimizerMetricsHistory
.map((m) => ({ step: m.step, d: m.d }))
.filter((m): m is { step: number; d: number } => typeof m.d === 'number')
.map((m) => ({ step: m.step, value: m.d }))

return (
<div className="flex flex-col gap-3.5 p-4 h-full overflow-y-auto m-page-scroll m-p-sm">
{/* Connection status + progress */}
Expand Down Expand Up @@ -1165,7 +1170,7 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
<SmoothControl alpha={emaAlpha} setAlpha={setEmaAlpha} min={0.001} max={0.3} step={0.001} />
</div>
<SeriesChart
data={losses.map((l) => ({ step: l.step, value: l.loss }))}
data={lossSeries}
rawColor="color-mix(in srgb, var(--fg-secondary) 35%, transparent)"
smoothColor="var(--accent)"
fillColor="var(--accent-soft)"
Expand All @@ -1190,19 +1195,11 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
/>
</div>

<div className="card p-4 flex-1 flex flex-col monitor-chart-card" style={{ minHeight: 140, maxHeight: 300 }}>
<div className="flex items-center justify-between mb-2 shrink-0">
<span className="text-sm font-semibold">learning rate</span>
<SmoothControl alpha={lrAlpha} setAlpha={setLrAlpha} min={0.005} max={1} step={0.005} />
</div>
<SeriesChart
data={lrSeries}
rawColor="rgba(224,162,58,0.35)"
smoothColor="var(--warn)"
emaAlpha={lrAlpha}
yFormat={fmtLr}
minHeight={60}
/>
{/* d value (Prodigy / D-Adaptation etc.) -- only when the optimizer reports it. */}
{dSeries.length > 0 && (
<div className="card p-4 flex-1 flex flex-col monitor-chart-card" style={{ minHeight: 140, maxHeight: 300 }}>
<div className="flex items-center justify-between mb-2 shrink-0">
<span className="text-sm font-semibold">d</span>
</div>
<SeriesChart
data={dSeries}
Expand All @@ -1213,7 +1210,9 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
minHeight={60}
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}
12 changes: 10 additions & 2 deletions studio/web/src/components/TaskSampleStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* when the delta actually contains appended_samples (training emits an image every few dozen steps,
* no need to jitter on every single delta).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

import { api, type TaskSample } from '../api/client'
Expand All @@ -40,7 +40,11 @@ function shortMarks(s: TaskSample): string {
return parts.join(' · ')
}

export default function TaskSampleStrip({ taskId, live = false }: {
// Memoized: the queue page re-renders on every monitor_progress delta of the running task;
// the strip (and its open lightbox) only depends on taskId / live.
export default memo(TaskSampleStrip)

function TaskSampleStrip({ taskId, live = false }: {
taskId: number
/** running task -> subscribes to monitor_progress, auto-appends new images. */
live?: boolean
Expand Down Expand Up @@ -159,6 +163,7 @@ export default function TaskSampleStrip({ taskId, live = false }: {
src={api.sampleImageUrl(s.filename, taskId, THUMB_REQ)}
alt=""
loading="lazy"
decoding="async"
/>
</span>
{caption && <span className="ds-sample-cap">{caption}</span>}
Expand All @@ -180,6 +185,9 @@ export default function TaskSampleStrip({ taskId, live = false }: {
onClose={() => setZoomIdx(null)}
onPrev={() => setZoomIdx((i) => Math.max(0, (i ?? 0) - 1))}
onNext={() => setZoomIdx((i) => Math.min(items.length - 1, (i ?? 0) + 1))}
preload={[items[zoomIdx - 1], items[zoomIdx + 1]]
.filter((x): x is TaskSample => !!x)
.map((x) => api.sampleImageUrl(x.filename, taskId))}
/>
)}
</div>
Expand Down
10 changes: 8 additions & 2 deletions studio/web/src/components/ZoomableImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useZoomPan } from '../lib/useZoomPan'

Expand Down Expand Up @@ -36,13 +36,17 @@ export default function ZoomableImage({
readout?: boolean
}) {
const { t } = useTranslation()
// Not reset when src changes: the browser keeps painting the current image until the
// next one has loaded and decoded, so flipping through a sequence never flashes an
// empty viewport. onLoad then stores the new size and we refit before the frame paints.
const [nat, setNat] = useState<{ w: number; h: number } | null>(null)
useEffect(() => { setNat(null) }, [src])
const zp = useZoomPan({
contentW: nat?.w ?? 0,
contentH: nat?.h ?? 0,
primaryButtonPans: true,
})
const { fit } = zp
useLayoutEffect(() => { if (nat) fit() }, [nat, fit])

return (
<div
Expand All @@ -61,6 +65,7 @@ export default function ZoomableImage({
src={src}
alt={alt}
draggable={false}
decoding="async"
onLoad={(e) => {
const w = e.currentTarget.naturalWidth
const h = e.currentTarget.naturalHeight
Expand All @@ -75,6 +80,7 @@ export default function ZoomableImage({
transformOrigin: '0 0',
maxWidth: 'none',
maxHeight: 'none',
willChange: 'transform',
visibility: nat ? 'visible' : 'hidden',
}}
/>
Expand Down
21 changes: 21 additions & 0 deletions studio/web/src/lib/useEventStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ function _maybeClose(): void {
}
}

/**
* Non-hook subscription to the same shared stream, for module-level stores that
* outlive any single component. onOpen fires on every (re)connect, and right away
* when the shared connection is already open. Returns the unsubscribe function.
*/
export function subscribeEventStream(onEvent: Listener, onOpen?: OpenListener): () => void {
if (typeof EventSource === 'undefined') return () => {}
const openHandler: OpenListener = () => onOpen?.()
_listeners.add(onEvent)
_openListeners.add(openHandler)
_ensureOpen()
if (_es && _es.readyState === EventSource.OPEN) {
try { onOpen?.() } catch { /* ignore */ }
}
return () => {
_listeners.delete(onEvent)
_openListeners.delete(openHandler)
_maybeClose()
}
}

/**
* Subscribes to the /api/events SSE stream. The callback fires once per event.
* Reconnects automatically on disconnect (this is just EventSource's built-in behavior).
Expand Down
Loading
Loading