// Admin.jsx — backend dashboard. Same SPA, ?view=admin gated on user.is_admin. // Design from the original webapp/Admin.jsx (the one we liked), wiring uses // window.API (defined in api.jsx) so all data is live from canon_lm_server. // // One file holds: AdminShell + System / Database / Users / Threads / Datasets / // Training views + the training log-tail subpanel. function AdminShell({ user, initial, onExitToChat, onSignOut }) { const PAGES = [ { id: 'engine', label: 'Engine', hint: 'M3 LLM start / stop' }, { id: 'system', label: 'System', hint: 'CPU · RAM · GPU · disk' }, { id: 'database', label: 'Database', hint: 'users · threads · messages' }, { id: 'users', label: 'Users', hint: 'manage accounts' }, { id: 'invites', label: 'Invites', hint: 'issue / revoke codes' }, { id: 'threads', label: 'Threads', hint: 'all conversations' }, { id: 'datasets', label: 'Datasets', hint: 'upload corpora' }, { id: 'training', label: 'Training', hint: 'launch + monitor jobs' }, ]; const [page, setPage] = React.useState(initial || 'system'); const [confirmSignOut, setConfirmSignOut] = React.useState(false); React.useEffect(() => { const u = new URL(location.href); u.searchParams.set('view', 'admin'); u.searchParams.set('sec', page); history.replaceState({}, '', u); }, [page]); return ( <>
Admin / {PAGES.find(p => p.id === page).label}
{user.display_name || user.email} admin
{page === 'engine' && } {page === 'system' && } {page === 'database' && } {page === 'users' && } {page === 'invites' && } {page === 'threads' && } {page === 'datasets' && } {page === 'training' && }
{confirmSignOut && setConfirmSignOut(false)} onConfirm={onSignOut}/>} ); } // ---------- shared helpers ---------- function fmtBytes(b) { if (b == null) return '—'; if (b < 1024) return b + ' B'; const units = ['KB', 'MB', 'GB', 'TB']; let v = b / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]; } function fmtDuration(sec) { if (sec == null || isNaN(sec)) return '—'; const s = Math.floor(sec % 60); const m = Math.floor((sec / 60) % 60); const h = Math.floor((sec / 3600) % 24); const d = Math.floor(sec / 86400); if (d > 0) return d + 'd ' + h + 'h ' + m + 'm'; if (h > 0) return h + 'h ' + m + 'm ' + s + 's'; if (m > 0) return m + 'm ' + s + 's'; return s + 's'; } function fmtPercent(p) { return p == null ? '—' : p.toFixed(1) + '%'; } function fmtDate(epoch) { if (!epoch) return '—'; const d = new Date(epoch * 1000); const pad = n => String(n).padStart(2, '0'); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } function MetricCard({ label, value, sub }) { return (
{label}
{value}
{sub &&
{sub}
}
); } function ProgressBar({ percent }) { const p = Math.max(0, Math.min(100, percent || 0)); const color = p > 90 ? 'var(--zg-danger)' : p > 75 ? 'var(--zg-warning)' : 'var(--accent)'; return (
); } // 401 → re-login. Re-used by every view. function on401(e) { if (e && e.status === 401) { window.API.clear(); location.reload(); } } // ---------- Engine view (M3 LLM start/stop) ---------- function AdminEngineView() { const [status, setStatus] = React.useState(null); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(null); // 'start' | 'stop' | null const [tick, setTick] = React.useState(0); const [auto, setAuto] = React.useState(true); const [lastFetch, setLast] = React.useState(null); const fetchStatus = React.useCallback(() => { return window.API.json('/admin/engine/status') .then(s => { setStatus(s); setErr(null); setLast(Date.now()); }) .catch(e => { on401(e); setErr(e.message); }); }, []); React.useEffect(() => { fetchStatus(); }, [tick, fetchStatus]); React.useEffect(() => { if (!auto) return; const id = setInterval(() => setTick(t => t + 1), 5000); return () => clearInterval(id); }, [auto]); const start = async () => { if (busy) return; setBusy('start'); try { const r = await window.API.json('/admin/engine/start', { method: 'POST' }); setStatus(r.status); setLast(Date.now()); if (!r.ok) alert('Start did not confirm · ' + r.detail); } catch (e) { on401(e); alert(e.message); } finally { setBusy(null); } }; const stop = async () => { if (busy) return; if (!window.confirm('Stop the M3 LLM engine? Active chats will get errors until restart.')) return; setBusy('stop'); try { const r = await window.API.json('/admin/engine/stop', { method: 'POST' }); setStatus(r.status); setLast(Date.now()); if (!r.ok) alert('Stop did not confirm · ' + r.detail); } catch (e) { on401(e); alert(e.message); } finally { setBusy(null); } }; if (err && !status) return
{err}
; if (!status) return
Loading engine status …
; const lastAgo = lastFetch ? Math.floor((Date.now() - lastFetch) / 1000) + 's ago' : '—'; const badgeColor = status.running ? 'var(--zg-success)' : 'var(--zg-danger)'; const badgeBg = status.running ? 'rgba(111,158,92,0.12)' : 'rgba(201,92,74,0.12)'; return (
last check · {lastAgo}
M3 zigu_engine_rs
{status.running && } {status.running ? 'running' : 'stopped'} {status.version && v{status.version}}
관리 엔진 URL{status.url}
SSH 대상{status.ssh_target}
{status.chat_upstream && (
채팅 라우팅{status.chat_upstream} {status.chat_track === 'stable' ? 'STABLE' : 'LATEST'}
)} {status.stable_url && (
Stable sidecar{status.stable_url} {status.stable_running ? '● 가동' : '○ 정지'}
)}
{status.error && (
{status.error.slice(0, 240)}
)}

About

  • Rust binary on M3 reached over Tailscale.
  • Stub mode unless rebuilt with --features model. Stub returns "day4-stub" tokens for any prompt.
  • Start spawns nohup ... & disown over SSH; stop sends SIGTERM to the engine PID.
  • Chat in the right rail routes through this engine. Stopping it surfaces an error in the next message.
); } // ---------- System view ---------- function AdminSystemView() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [tick, setTick] = React.useState(0); const [autoRefresh, setAutoRefresh] = React.useState(true); React.useEffect(() => { let cancelled = false; window.API.adminSystem() .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick]); React.useEffect(() => { if (!autoRefresh) return; const id = setInterval(() => setTick(t => t + 1), 3000); return () => clearInterval(id); }, [autoRefresh]); if (err && !data) return
{err}
; if (!data) return
Loading host metrics …
; const { system, cpu, memory, disks, gpu } = data; return (
refresh every 3 s

Host

Hostname{system.hostname}
Platform{system.platform}
Python{system.python}
Server uptime{fmtDuration(system.server_uptime_sec)}

CPU {cpu.count_physical} physical · {cpu.count_logical} logical

Total
{fmtPercent(cpu.percent)}
{cpu.per_cpu && cpu.per_cpu.length > 0 && (
{cpu.per_cpu.map((p, i) => (
cpu{i}
{fmtPercent(p)}
))}
)}

Memory

RAM
{fmtPercent(memory.percent)}
Total{fmtBytes(memory.total_bytes)}
Used{fmtBytes(memory.used_bytes)}
Available{fmtBytes(memory.available_bytes)}

GPU

{gpu.available ? ( <>
Device{gpu.name}
Count{gpu.device_count}
Allocated
{fmtBytes(gpu.memory_allocated_bytes)} / {fmtBytes(gpu.memory_total_bytes)}
Reserved
{fmtBytes(gpu.memory_reserved_bytes)}
{gpu.utilization_percent != null && (
Utilization
{fmtPercent(gpu.utilization_percent)}
)} ) : (
No CUDA device detected. Training will run on CPU.
)}

Disks

{disks.map(d => (
{d.label} {d.path}
usage
{fmtBytes(d.used_bytes)} / {fmtBytes(d.total_bytes)} · {fmtPercent(d.percent)}
))}
); } // ---------- Database view ---------- function AdminDatabaseView() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [tick, setTick] = React.useState(0); React.useEffect(() => { let cancelled = false; window.API.adminDb() .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick]); if (err && !data) return
{err}
; if (!data) return
Loading DB stats …
; return (
sqlite

Storage location

{data.db_path}
); } // ---------- Users view ---------- function AdminUsersView({ currentUserId }) { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [q, setQ] = React.useState(''); const [tick, setTick] = React.useState(0); const [busyId, setBusyId] = React.useState(null); const [confirmDel, setConfirmDel] = React.useState(null); const [addOpen, setAddOpen] = React.useState(false); const [resetOut, setResetOut] = React.useState(null); // {email, temporary_password} React.useEffect(() => { let cancelled = false; window.API.adminListUsers(q) .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick, q]); const refresh = () => setTick(t => t + 1); const toggleAdmin = async (u) => { if (u.id === currentUserId && u.is_admin) { if (!window.confirm('자신의 admin 권한을 해제하시겠어요? 이 세션에서 관리 기능을 잃습니다.')) return; } setBusyId(u.id); try { await window.API.adminToggleUserAdmin(u.id, !u.is_admin); refresh(); } catch (e) { on401(e); alert(e.message); } finally { setBusyId(null); } }; const toggleActive = async (u) => { setBusyId(u.id); try { await window.API.adminUpdateUser(u.id, { is_active: !u.is_active }); refresh(); } catch (e) { on401(e); alert(e.message); } finally { setBusyId(null); } }; const resetPw = async (u) => { if (!window.confirm(`'${u.email}' 의 비밀번호를 임시 비번으로 초기화하시겠어요?\n새 비번은 한 번만 표시되며 본인에게 직접 전달해야 합니다.`)) return; setBusyId(u.id); try { const r = await window.API.adminResetUserPassword(u.id); setResetOut(r); } catch (e) { on401(e); alert(e.message); } finally { setBusyId(null); } }; const remove = async (u) => { setBusyId(u.id); try { await window.API.adminDeleteUser(u.id); refresh(); } catch (e) { on401(e); alert(e.message); } finally { setBusyId(null); setConfirmDel(null); } }; return (
setQ(e.target.value)}/> {data ? data.length + ' users' : ''}
{err &&
{err}
} {!data && !err &&
Loading users …
} {data && ( {data.map(u => { const isSelf = u.id === currentUserId; const activeAdminCount = data.filter(x => x.is_admin && x.is_active).length; const lastAdmin = u.is_admin && activeAdminCount === 1; return ( ); })} {data.length === 0 && }
EmailNameAdmin상태 ThreadsMessages Last loginJoined
{u.email}{isSelf && you} {u.display_name || } {u.thread_count} {u.message_count} {u.last_login_at ? fmtDate(u.last_login_at) : '—'} {fmtDate(u.created_at)}
No users match.
)} {confirmDel && setConfirmDel(null)} onConfirm={() => remove(confirmDel)}/>} {addOpen && setAddOpen(false)} onCreated={() => { setAddOpen(false); refresh(); }}/>} {resetOut && setResetOut(null)}/>}
); } function AdminCreateUserModal({ onClose, onCreated }) { const [email, setEmail] = React.useState(''); const [pw, setPw] = React.useState(''); const [name, setName] = React.useState(''); const [isAdmin, setIsAdmin] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const valid = email.includes('@') && pw.length >= 8; const submit = async () => { if (!valid || busy) return; setBusy(true); setErr(null); try { await window.API.adminCreateUser({ email: email.trim(), password: pw, display_name: name.trim() || null, is_admin: !!isAdmin, }); onCreated(); } catch (e) { on401(e); setErr(e.message || '생성 실패'); } finally { setBusy(false); } }; return (
e.stopPropagation()}>
Admin · Users

사용자 추가

초대 코드 없이 즉시 활성 계정을 생성합니다. 본인에게 비밀번호를 직접 전달하세요.

이메일
setEmail(e.target.value)} autoComplete="off"/>
초기 비밀번호 8자 이상
setPw(e.target.value)} autoComplete="new-password" style={{fontFamily:'var(--font-mono)'}}/>
표시 이름 선택
setName(e.target.value)} maxLength={80}/>
관리자 권한
{err &&
{err}
}
); } function AdminResetResultModal({ out, onClose }) { const [copied, setCopied] = React.useState(false); const copy = async () => { try { await navigator.clipboard.writeText(out.temporary_password); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch (_) {} }; return (
e.stopPropagation()}>
Admin · Users · 비밀번호 초기화

임시 비밀번호 발급됨

이 비밀번호는 한 번만 표시됩니다. 창을 닫기 전에 복사하여 본인에게 직접 전달하세요. 사용자에게 로그인 후 즉시 비밀번호를 변경하도록 안내하세요.

대상
{out.email}
임시 비밀번호
); } // ---------- Invites view ---------- function AdminInvitesView() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [tick, setTick] = React.useState(0); const [busy, setBusy] = React.useState(false); const [note, setNote] = React.useState(''); const [newCode, setNewCode] = React.useState(null); React.useEffect(() => { let cancelled = false; window.API.adminListInvites() .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick]); const refresh = () => setTick(t => t + 1); const create = async () => { setBusy(true); try { const inv = await window.API.adminCreateInvite({ note: note.trim() || null }); setNote(''); setNewCode(inv); refresh(); } catch (e) { on401(e); alert(e.message); } finally { setBusy(false); } }; const revoke = async (code) => { if (!window.confirm(`초대 코드 '${code}' 를 폐기하시겠어요?`)) return; setBusy(true); try { await window.API.adminDeleteInvite(code); refresh(); } catch (e) { on401(e); alert(e.message); } finally { setBusy(false); } }; const copy = async (text) => { try { await navigator.clipboard.writeText(text); } catch (_) {} }; const now = Math.floor(Date.now() / 1000); return (
setNote(e.target.value)} maxLength={200}/> {data ? data.length + ' invites' : ''}
{err &&
{err}
} {!data && !err &&
Loading invites …
} {data && ( {data.map(inv => { const used = inv.used_by_user_id != null; const expired = !used && inv.expires_at < now; const state = used ? '사용됨' : (expired ? '만료' : '활성'); return ( ); })} {data.length === 0 && }
Code상태발급자사용자 발급만료메모
{state} {inv.created_by_email || ('#' + inv.created_by_user_id)} {inv.used_by_email || } {fmtDate(inv.created_at)} {fmtDate(inv.expires_at)} {inv.note || } {!used && ( )}
발급된 초대 코드가 없습니다.
)} {newCode && (
setNewCode(null)}>
e.stopPropagation()}>
Admin · Invites

초대 코드 발급됨

아래 코드를 본인에게 전달하세요. 1회용이며 {fmtDate(newCode.expires_at)} 까지 유효합니다.

코드
)}
); } // ---------- Threads view ---------- function AdminThreadsView() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [q, setQ] = React.useState(''); const [tick, setTick] = React.useState(0); const [busyId, setBusyId] = React.useState(null); React.useEffect(() => { let cancelled = false; window.API.adminListThreads(q) .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick, q]); const remove = async (t) => { if (!window.confirm('Delete thread "' + t.title + '" (owner ' + t.user_email + ', ' + t.message_count + ' msgs)?')) return; setBusyId(t.id); try { await window.API.adminDeleteThread(t.id); setTick(x => x + 1); } catch (e) { on401(e); alert(e.message); } finally { setBusyId(null); } }; return (
setQ(e.target.value)}/> {data ? data.length + ' threads' : ''}
{err &&
{err}
} {!data && !err &&
Loading threads …
} {data && ( {data.map(t => ( ))} {data.length === 0 && }
TitleOwnerModelMsgsUpdated
{t.pinned && pinned}{t.title} {t.user_email} {t.model} {t.message_count} {fmtDate(t.updated_at)}
No threads match.
)}
); } // ---------- Datasets view ---------- function AdminDatasetsView() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [tick, setTick] = React.useState(0); const [uploading, setUploading] = React.useState(false); const [uploadProgress, setUploadProgress] = React.useState(null); const fileInputRef = React.useRef(null); React.useEffect(() => { let cancelled = false; window.API.adminListDatasets() .then(j => { if (!cancelled) { setData(j); setErr(null); } }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; }, [tick]); const handleFile = async (file) => { if (!file) return; if (file.size > 200 * 1024 * 1024) { alert('File exceeds 200 MB cap.'); return; } setUploading(true); setUploadProgress({ name: file.name, sent: 0, total: file.size }); try { await window.API.adminUploadDataset(file, (sent, total) => setUploadProgress({ name: file.name, sent, total })); setTick(t => t + 1); } catch (e) { on401(e); alert('Upload failed · ' + e.message); } finally { setUploading(false); setUploadProgress(null); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const deleteDataset = async (d) => { if (d.kind === 'directory') { alert('Directories cannot be deleted via this UI.'); return; } if (!window.confirm('Delete ' + d.name + ' (' + fmtBytes(d.size_bytes) + ')?')) return; try { await window.API.adminDeleteDataset(d.name); setTick(t => t + 1); } catch (e) { on401(e); alert(e.message); } }; const downloadDataset = (d) => { if (d.kind === 'directory') { alert('Directories cannot be downloaded via this UI.'); return; } window.API.adminDownloadDataset(d.name).catch(e => { on401(e); alert('Download failed · ' + e.message); }); }; return (

Upload corpus

handleFile(e.target.files && e.target.files[0])} disabled={uploading} /> accepted: .txt .csv .tsv .jsonl .json .md · max 200 MB
{uploadProgress && (
{uploadProgress.name}
{fmtBytes(uploadProgress.sent)} / {fmtBytes(uploadProgress.total)}
)}
data/ {data ? data.length + ' entries' : ''}
{err &&
{err}
} {!data && !err &&
Loading datasets …
} {data && ( {data.map(d => ( ))} {data.length === 0 && }
NameKindSizeModified
{d.name} {d.kind} {d.ext && {d.ext}} {fmtBytes(d.size_bytes)} {fmtDate(d.modified_at)} {d.kind === 'file' && <> }
No datasets. Upload one above.
)}
); } // ---------- Training view + log tail ---------- function StatusBadge({ status }) { const map = { running: { color: 'var(--accent)', bg: 'var(--accent-tint)' }, completed: { color: 'var(--zg-success)', bg: 'rgba(111,158,92,0.12)' }, failed: { color: 'var(--zg-danger)', bg: 'rgba(201,92,74,0.12)' }, stopped: { color: 'var(--fg-3)', bg: 'var(--bg-elevated)' }, queued: { color: 'var(--zg-warning)', bg: 'rgba(217,162,62,0.12)' }, }; const c = map[status] || { color: 'var(--fg-3)', bg: 'var(--bg-elevated)' }; return ( {status === 'running' && } {status} ); } function AdminTrainingView() { const [scripts, setScripts] = React.useState(null); const [jobs, setJobs] = React.useState(null); const [err, setErr] = React.useState(null); const [selectedScript, setSelectedScript] = React.useState(''); const [launching, setLaunching] = React.useState(false); const [selectedJobId, setSelectedJobId] = React.useState(null); const [tick, setTick] = React.useState(0); const [confirmLaunch, setConfirmLaunch] = React.useState(false); const [confirmStop, setConfirmStop] = React.useState(null); React.useEffect(() => { let cancelled = false; Promise.all([ window.API.adminListTrainingScripts(), window.API.adminListTrainingJobs(50), ]) .then(([s, j]) => { if (cancelled) return; setScripts(s); if (s.length && !selectedScript) setSelectedScript(s[s.length - 1].name); setJobs(j); if (!selectedJobId && j.length) setSelectedJobId(j[0].id); setErr(null); }) .catch(e => { if (cancelled) return; on401(e); setErr(e.message); }); return () => { cancelled = true; }; // eslint-disable-next-line }, [tick]); // re-poll when any job is running React.useEffect(() => { const hasRunning = jobs && jobs.some(j => j.status === 'running'); if (!hasRunning) return; const id = setInterval(() => setTick(t => t + 1), 3000); return () => clearInterval(id); }, [jobs]); const runningJob = jobs && jobs.find(j => j.status === 'running'); const slotFree = !runningJob; const launch = async () => { setConfirmLaunch(false); if (!selectedScript) { alert('Pick a script first.'); return; } setLaunching(true); try { const created = await window.API.adminLaunchTraining(selectedScript); setSelectedJobId(created.id); setTick(t => t + 1); } catch (e) { on401(e); alert(e.message); } finally { setLaunching(false); } }; const stop = async (j) => { setConfirmStop(null); try { await window.API.adminStopTraining(j.id); setTick(t => t + 1); } catch (e) { on401(e); alert('Stop failed · ' + e.message); } }; const remove = async (j) => { if (!window.confirm('Delete record of job #' + j.id + ' and its log?')) return; try { await window.API.adminDeleteTrainingJob(j.id); if (selectedJobId === j.id) setSelectedJobId(null); setTick(t => t + 1); } catch (e) { on401(e); alert(e.message); } }; return (

Launch a training job {slotFree ? '◯ slot free' : '● slot busy · job #' + runningJob.id}

Single-slot: only one training subprocess at a time (GPU contention prevention). New launches are blocked while a job is running.
Jobs {jobs ? jobs.length + ' total' : ''}
{err &&
{err}
} {!jobs && !err &&
Loading jobs …
} {jobs && ( {jobs.map(j => { const dur = j.finished_at ? j.finished_at - j.started_at : j.status === 'running' ? (Date.now()/1000 - j.started_at) : null; return ( setSelectedJobId(j.id)} style={{cursor:'pointer'}}> ); })} {jobs.length === 0 && }
IDScriptStatusPIDStartedDurationBy
#{j.id} {j.script_name} {j.pid || '—'} {fmtDate(j.started_at)} {dur != null ? fmtDuration(dur) : '—'} {j.started_by_email} e.stopPropagation()}> {j.status === 'running' ? : }
No jobs yet. Pick a script above and launch.
)} {selectedJobId != null && setSelectedJobId(null)}/>} {confirmLaunch && setConfirmLaunch(false)} onConfirm={launch}/>} {confirmStop && setConfirmStop(null)} onConfirm={() => stop(confirmStop)}/>}
); } function TrainingLogTail({ jobId, onClose }) { const [content, setContent] = React.useState(''); const [status, setStatus] = React.useState(''); const offsetRef = React.useRef(0); const preRef = React.useRef(null); const autoScrollRef = React.useRef(true); React.useEffect(() => { offsetRef.current = 0; setContent(''); setStatus(''); }, [jobId]); React.useEffect(() => { let cancelled = false; let timer = null; const poll = async () => { if (cancelled) return; try { const j = await window.API.adminTrainingLogChunk(jobId, offsetRef.current, 64 * 1024); if (cancelled) return; if (j.content) { setContent(prev => prev + j.content); offsetRef.current = j.next_offset; } setStatus(j.status); const delay = j.status === 'running' ? 1500 : 4000; timer = setTimeout(poll, delay); } catch (e) { if (!cancelled) timer = setTimeout(poll, 5000); } }; poll(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [jobId]); React.useEffect(() => { if (autoScrollRef.current && preRef.current) { preRef.current.scrollTop = preRef.current.scrollHeight; } }, [content]); const onScroll = (e) => { const el = e.target; autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }; return (

Log · job #{jobId} {status}

{content || '(no log yet — waiting for first flush)'}
); } Object.assign(window, { AdminShell }); // Training lives in the same module now; export a stub so the old