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