diff --git a/api/routes.py b/api/routes.py index 521cb6ac..a7bacefc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18,6 +18,38 @@ from urllib.parse import parse_qs logger = logging.getLogger(__name__) +# ── Cron run tracking ──────────────────────────────────────────────────────── +# Track job IDs currently being executed so the frontend can poll status. +_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp +_RUNNING_CRON_LOCK = threading.Lock() + + +def _mark_cron_running(job_id: str): + with _RUNNING_CRON_LOCK: + _RUNNING_CRON_JOBS[job_id] = time.time() + + +def _mark_cron_done(job_id: str): + with _RUNNING_CRON_LOCK: + _RUNNING_CRON_JOBS.pop(job_id, None) + + +def _is_cron_running(job_id: str) -> tuple[bool, float]: + """Return (is_running, elapsed_seconds).""" + with _RUNNING_CRON_LOCK: + t = _RUNNING_CRON_JOBS.get(job_id) + if t is None: + return False, 0.0 + return True, time.time() - t + + +def _run_cron_tracked(job): + """Wrapper that tracks running state around cron.scheduler.run_job.""" + try: + run_job(job) + finally: + _mark_cron_done(job.get("id", "")) + _PROVIDER_ALIASES = { "claude": "anthropic", "gpt": "openai", @@ -1115,6 +1147,9 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/crons/recent": return _handle_cron_recent(handler, parsed) + if parsed.path == "/api/crons/status": + return _handle_cron_status(handler, parsed) + # ── Skills API (GET) ── if parsed.path == "/api/skills": from tools.skills_tool import skills_list as _skills_list @@ -2640,6 +2675,19 @@ def _handle_cron_output(handler, parsed): return j(handler, {"job_id": job_id, "outputs": outputs}) +def _handle_cron_status(handler, parsed): + """Return running status for one or all cron jobs.""" + qs = parse_qs(parsed.query) + job_id = qs.get("job_id", [""])[0] + if job_id: + running, elapsed = _is_cron_running(job_id) + return j(handler, {"job_id": job_id, "running": running, "elapsed": round(elapsed, 1)}) + # Return status for all running jobs + with _RUNNING_CRON_LOCK: + all_running = {jid: round(time.time() - t, 1) for jid, t in _RUNNING_CRON_JOBS.items()} + return j(handler, {"running": all_running}) + + def _handle_cron_recent(handler, parsed): """Return cron jobs that have completed since a given timestamp.""" import datetime @@ -3114,8 +3162,9 @@ def _handle_cron_run(handler, body): job = get_job(job_id) if not job: return bad(handler, "Job not found", 404) - threading.Thread(target=run_job, args=(job,), daemon=True).start() - return j(handler, {"ok": True, "job_id": job_id, "status": "triggered"}) + _mark_cron_running(job_id) + threading.Thread(target=_run_cron_tracked, args=(job,), daemon=True).start() + return j(handler, {"ok": True, "job_id": job_id, "status": "running"}) def _handle_cron_pause(handler, body): diff --git a/static/i18n.js b/static/i18n.js index e7f867f0..8b6db00c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -4150,7 +4150,7 @@ const LOCALES = { cron_status_paused: 'paused', cron_status_error: 'error', cron_status_active: 'active', - cron_status_running: 'running\u2026', + cron_status_running: '\uc2e4\ud589 \uc911\u2026', cron_status_needs_attention: '주의 필요', cron_attention_desc: '이 반복 작업에 다음 실행 시간이 없습니다. 스케줄러가 다음 실행을 계산하지 못했을 수 있습니다.', cron_attention_croniter_hint: 'Gateway 런타임에 croniter 패키지가 없을 수 있습니다. cron 지원이 포함된 환경으로 Gateway를 재시작한 후 이 작업을 재개하세요.', diff --git a/static/panels.js b/static/panels.js index a910eac8..201c4bff 100644 --- a/static/panels.js +++ b/static/panels.js @@ -430,7 +430,9 @@ function openCronDetail(id, el){ if (dot) dot.remove(); _cronPreFormDetail = null; _editingCronId = null; + _stopCronWatch(); _renderCronDetail(job); + _checkCronWatchOnDetail(id); } function _clearCronDetail(){ @@ -708,11 +710,83 @@ function _cronOutputSnippet(content) { return body.slice(0, 600) || '(empty)'; } +// ── Cron run watch ──────────────────────────────────────────────────────────── +let _cronWatchInterval = null; +let _cronWatchStart = null; +let _cronWatchTimerInterval = null; + +function _startCronWatch(jobId) { + _stopCronWatch(); + _cronWatchStart = Date.now(); + _cronWatchInterval = setInterval(async () => { + try { + const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`); + if (!data.running) { + _stopCronWatch(); + if (_currentCronDetail && _currentCronDetail.id === jobId) { + _loadCronDetailRuns(jobId); + } + return; + } + // Still running — update elapsed + if (_currentCronDetail && _currentCronDetail.id === jobId) { + const el = $('cronRunningIndicator'); + if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed(data.elapsed); + } + } catch(e) { /* ignore poll errors */ } + }, 3000); + // Timer update every second + _cronWatchTimerInterval = setInterval(() => { + if (_currentCronDetail && _cronWatchStart) { + const el = $('cronRunningIndicator'); + if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed((Date.now() - _cronWatchStart) / 1000); + } + }, 1000); + // Inject running indicator into detail card + if (_currentCronDetail && _currentCronDetail.id === jobId) { + _injectRunningIndicator(); + } +} + +function _stopCronWatch() { + if (_cronWatchInterval) { clearInterval(_cronWatchInterval); _cronWatchInterval = null; } + if (_cronWatchTimerInterval) { clearInterval(_cronWatchTimerInterval); _cronWatchTimerInterval = null; } + _cronWatchStart = null; + const el = $('cronRunningIndicator'); + if (el) el.remove(); +} + +function _injectRunningIndicator() { + const card = $('cronDetailRuns'); + if (!card || $('cronRunningIndicator')) return; + const div = document.createElement('div'); + div.id = 'cronRunningIndicator'; + div.className = 'cron-running-indicator'; + div.innerHTML = `${esc(t('cron_status_running'))}0s`; + card.insertAdjacentElement('beforebegin', div); +} + +function _formatElapsed(seconds) { + if (seconds < 60) return Math.round(seconds) + 's'; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return m + 'm ' + s + 's'; +} + +function _checkCronWatchOnDetail(jobId) { + // When opening a detail view, check if job is running + api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => { + if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) { + _startCronWatch(jobId); + } + }).catch(() => {}); +} + async function cronRun(id) { try { await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_triggered')); - setTimeout(() => { if (_currentCronDetail && _currentCronDetail.id === id) _loadCronDetailRuns(id); }, 5000); + _startCronWatch(id); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } diff --git a/static/style.css b/static/style.css index 4df04984..91762b33 100644 --- a/static/style.css +++ b/static/style.css @@ -2292,6 +2292,12 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);} .detail-alert p:last-child{margin-bottom:0;} .detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;} + +/* Cron running indicator */ +.cron-running-indicator{display:flex;align-items:center;gap:8px;padding:10px 14px;margin-bottom:12px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);border-radius:8px;font-size:13px;color:var(--text);} +.cron-watch-spinner{width:14px;height:14px;border:2px solid rgba(59,130,246,.3);border-top-color:rgba(59,130,246,.9);border-radius:50%;animation:cron-spin .8s linear infinite;flex-shrink:0;} +@keyframes cron-spin{to{transform:rotate(360deg)}} +.cron-watch-elapsed{color:var(--muted);font-variant-numeric:tabular-nums;margin-left:auto;} .detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;} .detail-run-item{border-top:1px solid var(--border);padding:8px 0;} .detail-run-item:first-child{border-top:none;}