/* 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 };