/* global React, CR_DATA, CR_UI, CR_API */ const { useState, useEffect, useRef, useCallback } = React; const { Icons, Btn, formatRelative } = CR_UI; // BRAND is passed as a prop from app.jsx — no hardcode here const JOB_KIND_LABEL = { find_topics: "find_topics", deep_research: "deep_research", refine_article: "refine_article", generate_post: "generate_post", bootstrap_brand:"bootstrap_brand", }; // tag parse: "OK line text" → { tag: "ok", text: "line text" } // tags in stdout: "OK " / "WARN" / "ERR " / "INFO" / "TOOL" const TAG_RE = /^(OK |WARN|ERR |INFO|TOOL)\s/; const TAG_CLASS = { "INFO": "info", "TOOL": "tool", "OK ": "ok", "ERR ": "err", "WARN": "warn" }; function parseLine(raw) { const m = TAG_RE.exec(raw); if (m) { const tag = m[1]; return { tag, tagClass: TAG_CLASS[tag] || "info", text: raw.slice(m[0].length) }; } return { tag: "INFO", tagClass: "info", text: raw }; } function fmtTime(iso) { if (!iso) return "—"; try { return new Date(iso).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } catch (_) { return "—"; } } function fmtDuration(startedAt, finishedAt) { if (!startedAt) return "—"; const end = finishedAt ? new Date(finishedAt) : new Date(); const sec = Math.round((end - new Date(startedAt)) / 1000); if (sec < 60) return `${sec}с`; const m = Math.floor(sec / 60), s = sec % 60; return `${m}:${String(s).padStart(2, "0")}`; } // Live version — uses an externally ticking `nowMs` so it re-renders each second. function fmtDurationLive(startedAt, nowMs) { if (!startedAt) return "—"; const sec = Math.round((nowMs - Date.parse(startedAt)) / 1000); if (sec < 0) return "0с"; if (sec < 60) return `${sec}с`; const m = Math.floor(sec / 60), s = sec % 60; return `${m}:${String(s).padStart(2, "0")}`; } // Extract "N/M" progress from last log line function extractProgress(lines) { for (let i = lines.length - 1; i >= 0; i--) { const m = /(\d+)\/(\d+)/.exec(lines[i]); if (m) return { current: Number(m[1]), total: Number(m[2]) }; } return null; } // STATUS icon — running→spinner, ok→check, failed/timeout/killed→X, else null const StatusIcon = ({ status }) => { if (status === "running" || status === "queued") return ; if (status === "ok") return ; if (status === "failed" || status === "timeout" || status === "killed") return ; return null; }; // ---- WebSocket manager hook ---- // Opens a WS per running job, fans lines into linesMap[id]. // Reconnects up to 3 times on unexpected close. // // Two effects on purpose: // 1) jobs-driven effect — opens new WSs for running jobs, closes WSs for // jobs that flipped to a terminal status. NO cleanup return — otherwise // every polling tick (5s) closes-and-reopens every WS, churning sockets // and duplicating tail replays from the backend. // 2) unmount-only effect — closes everything in socketsRef on component // teardown. Runs once. function useJobStreams(jobs, linesMap, setLinesMap) { const socketsRef = useRef({}); // id → { ws, retries, dead } // (1) jobs-driven effect useEffect(() => { const runningIds = new Set( jobs.filter((j) => j.status === "running" || j.status === "queued").map((j) => j.id) ); // open missing runningIds.forEach((id) => { if (socketsRef.current[id]) return; openSocket(id); }); // close sockets for jobs no longer running (terminal status flipped) Object.keys(socketsRef.current).forEach((id) => { if (!runningIds.has(id)) { const entry = socketsRef.current[id]; if (entry) { entry.dead = true; if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) { entry.ws.close(); } delete socketsRef.current[id]; } } }); function openSocket(id) { const entry = socketsRef.current[id] || { retries: 0, dead: false }; socketsRef.current[id] = entry; const ws = CR_API.jobLogSocket(id); // includes ?token= when CR_AUTH_TOKEN is set entry.ws = ws; ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === "line") { setLinesMap((prev) => { const cur = prev[id] || []; return { ...prev, [id]: [...cur, msg.line] }; }); } // "done" message — polling will pick up status change } catch (_) {} }; ws.onclose = () => { if (entry.dead) return; if (entry.retries < 3) { entry.retries += 1; setTimeout(() => { if (!entry.dead) openSocket(id); }, 2000); } }; ws.onerror = () => { /* handled in onclose */ }; } // No cleanup return — see comment above. }, [jobs]); // (2) unmount-only effect useEffect(() => { return () => { Object.keys(socketsRef.current).forEach((id) => { const entry = socketsRef.current[id]; if (entry) { entry.dead = true; if (entry.ws && (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING)) { entry.ws.close(); } } }); socketsRef.current = {}; }; }, []); } // Russian phase labels for bootstrap_brand jobs (from .bootstrap/status.json) const BOOTSTRAP_PHASE_LABEL = { downloading: "Скачивание постов из Telegram", downloaded: "Посты скачаны, ожидание Claude…", analyzing: "Claude анализирует канал и пишет конфиг", analyzed: "Готово", failed: "Ошибка", }; // ---- Main component ---- const JobLog = ({ open, onToggle, brand }) => { const [jobs, setJobs] = useState([]); const [linesMap, setLinesMap] = useState({}); // id → string[] const [activeId, setActiveId] = useState(null); const [killing, setKilling] = useState(false); const [now, setNow] = useState(() => Date.now()); // ticks each sec for live duration const [bootstrapStatus, setBootstrapStatus] = useState(null); // {phase, downloaded, channel, ...} const streamRef = useRef(null); // 1Hz tick for live duration display when there's at least one running job useEffect(() => { const hasRunning = jobs.some((j) => j.status === "running" || j.status === "queued"); if (!hasRunning) return; const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [jobs]); // Poll bootstrap-status every 3s for live phase/downloaded display // Only when there's an active bootstrap_brand job in progress useEffect(() => { const hasBootstrap = jobs.some( (j) => j.kind === "bootstrap_brand" && (j.status === "running" || j.status === "queued"), ); if (!hasBootstrap) { setBootstrapStatus(null); return; } let cancelled = false; async function load() { try { const s = await CR_API.telegramStatus(brand); if (!cancelled) setBootstrapStatus(s); } catch (_) {} } load(); const t = setInterval(load, 3000); return () => { cancelled = true; clearInterval(t); }; }, [jobs, brand]); // Polling: fetch job list every 5s; restart when brand changes useEffect(() => { if (!brand) return; let cancelled = false; async function poll() { try { const data = await CR_API.listJobs(brand); if (!cancelled) { setJobs(data); // Pre-populate linesMap with tail from existing jobs (for late-join) // The WS will stream new lines; existing tail comes from the backend response. // JobState has `tail: string[]` field. setLinesMap((prev) => { const next = { ...prev }; data.forEach((j) => { if (!next[j.id] && j.tail && j.tail.length) { next[j.id] = j.tail; } }); return next; }); } } catch (_) { // backend offline — keep whatever we have } } poll(); const interval = setInterval(poll, 5000); return () => { cancelled = true; clearInterval(interval); }; }, [brand]); // Auto-select most-relevant job when list changes. // Priority: keep current selection if still in list → first running job → // first job in list (which is now the newest, after backend sort fix). useEffect(() => { if (jobs.length === 0) { setActiveId(null); return; } setActiveId((prev) => { if (prev && jobs.find((j) => j.id === prev)) return prev; const running = jobs.find((j) => j.status === "running" || j.status === "queued"); if (running) return running.id; return jobs[0].id; }); }, [jobs]); // Auto-scroll stream pane on new lines useEffect(() => { if (open && streamRef.current) { streamRef.current.scrollTop = streamRef.current.scrollHeight; } }, [open, activeId, linesMap]); // Manage WS connections useJobStreams(jobs, linesMap, setLinesMap); const active = jobs.find((j) => j.id === activeId); const activeLines = (activeId && linesMap[activeId]) || []; const runningJobs = jobs.filter((j) => j.status === "running" || j.status === "queued"); const activeCount = runningJobs.length; // Bar: progress hint from last running job const barActiveJob = runningJobs[0]; const barLines = barActiveJob ? (linesMap[barActiveJob.id] || []) : []; const barProgress = barActiveJob ? extractProgress(barLines) : null; const handleKill = useCallback(async () => { if (!active || active.status !== "running") return; setKilling(true); try { await CR_API.killJob(active.id); // polling will update status } catch (_) {} setKilling(false); }, [active]); return (
{activeCount} активных · {jobs.length} недавних {activeCount > 0 && !open && barActiveJob && ( {JOB_KIND_LABEL[barActiveJob.kind] || barActiveJob.kind} {barProgress ? ` — ${barProgress.current}/${barProgress.total}` : ""} )}
Журнал {open ? : }
{open && (
{jobs.length === 0 ? (
Нет недавних запусков
) : ( <>
{jobs.map((j) => { const jLines = linesMap[j.id] || []; const jProgress = (j.status === "running" || j.status === "queued") ? extractProgress(jLines) : null; const isError = j.status === "failed" || j.status === "timeout" || j.status === "killed"; return (
setActiveId(j.id)}>
{JOB_KIND_LABEL[j.kind] || j.kind}
{j.brand} · {(j.status === "running" || j.status === "queued") ? `работает ${fmtDurationLive(j.started_at, now)}` : `${fmtDuration(j.started_at, j.finished_at)} · ${formatRelative(Date.parse(j.started_at))}`}
{(j.status === "running" || j.status === "queued") && jProgress && (
↳ {jProgress.current}/{jProgress.total}
)} {j.status === "ok" && j.cost_tokens && (
↳ {j.cost_tokens.toLocaleString("ru-RU")} токенов
)} {isError && j.error && (
↳ {j.error}
)} {isError && !j.error && j.status === "killed" && (
↳ прерван вручную
)}
); })}
{active && (
{JOB_KIND_LABEL[active.kind] || active.kind} {active.id} · pid {active.pid || "—"} {(active.status === "running" || active.status === "queued") && active.started_at && ( <> · работает {fmtDurationLive(active.started_at, now)} )} {active.status !== "running" && active.status !== "queued" && active.started_at && ( <> · {fmtDuration(active.started_at, active.finished_at)} )}
{(active.status === "running" || active.status === "queued") ? ( } onClick={handleKill} disabled={killing}> Прервать ) : ( }>Запустить повтор )}
{/* Bootstrap-specific live status banner: phase + downloaded count + elapsed */} {active.kind === "bootstrap_brand" && (active.status === "running" || active.status === "queued") && (
{bootstrapStatus ? (BOOTSTRAP_PHASE_LABEL[bootstrapStatus.phase] || bootstrapStatus.phase || "В работе") : "В работе"} {bootstrapStatus?.phase === "downloading" && bootstrapStatus.limit && ( {bootstrapStatus.downloaded || 0}/{bootstrapStatus.limit} постов )} {bootstrapStatus?.phase === "analyzing" && bootstrapStatus.downloaded != null && ( из {bootstrapStatus.downloaded} постов )} {/* Elapsed from heartbeat — shown even when stdout is silent */} {bootstrapStatus?.elapsed_sec > 0 && (() => { const sec = bootstrapStatus.elapsed_sec; const m = Math.floor(sec / 60), s = sec % 60; return ( {m > 0 ? `${m}м ${s}с` : `${s}с`} ); })()}
)}
{activeLines.length === 0 && (
{(active.status === "running" || active.status === "queued") ? ( <> Ожидание первого вывода… {active.started_at && ( ({fmtDurationLive(active.started_at, now)} без stdout) )} ) : ( "Лог не сохранён или job не оставил stdout." )}
)} {activeLines.map((raw, i) => { const { tag, tagClass, text } = parseLine(raw); // Extract timestamp prefix "HH:MM:SS " if present const timeM = /^(\d{2}:\d{2}:\d{2})\s(.*)$/.exec(text); const displayTime = timeM ? timeM[1] : fmtTime(active.started_at); const displayText = timeM ? timeM[2] : text; return (
{displayTime} {tag} {displayText}
); })} {(active.status === "running" || active.status === "queued") && (
{fmtTime(new Date().toISOString())} INFO выполняется…
)}
)} )}
)}
); }; window.CR_JOBLOG = { JobLog };