// 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 (
<>
{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) => (
))}
)}
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 && (
| Email | Name | Admin | 상태 |
Threads | Messages |
Last login | Joined | |
{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 (
| {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)} |
|
);
})}
{data.length === 0 && | 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()}>
초대 코드 없이 즉시 활성 계정을 생성합니다. 본인에게 비밀번호를 직접 전달하세요.
{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 · 비밀번호 초기화
임시 비밀번호 발급됨
이 비밀번호는 한 번만 표시됩니다. 창을 닫기 전에 복사하여 본인에게 직접 전달하세요.
사용자에게 로그인 후 즉시 비밀번호를 변경하도록 안내하세요.
);
}
// ---------- 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 && (
| Code | 상태 | 발급자 | 사용자 |
발급 | 만료 | 메모 | |
{data.map(inv => {
const used = inv.used_by_user_id != null;
const expired = !used && inv.expires_at < now;
const state = used ? '사용됨' : (expired ? '만료' : '활성');
return (
|
|
{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 && (
)}
|
);
})}
{data.length === 0 && | 발급된 초대 코드가 없습니다. |
}
)}
{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 && (
| Title | Owner | Model | Msgs | Updated | |
{data.map(t => (
| {t.pinned && pinned}{t.title} |
{t.user_email} |
{t.model} |
{t.message_count} |
{fmtDate(t.updated_at)} |
|
))}
{data.length === 0 && | 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 && (
| Name | Kind | Size | Modified | |
{data.map(d => (
| {d.name} |
{d.kind}
{d.ext && {d.ext}}
|
{fmtBytes(d.size_bytes)} |
{fmtDate(d.modified_at)} |
{d.kind === 'file' && <>
>}
|
))}
{data.length === 0 && | 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 && (
| ID | Script | Status | PID | Started | Duration | By | |
{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'}}>
| #{j.id} |
{j.script_name} |
|
{j.pid || '—'} |
{fmtDate(j.started_at)} |
{dur != null ? fmtDuration(dur) : '—'} |
{j.started_by_email} |
e.stopPropagation()}>
{j.status === 'running'
?
: }
|
);
})}
{jobs.length === 0 && | 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