feat: cron run status tracking and watch mode (#526)
Backend:
- Track running cron jobs in thread-safe dict (job_id → start_time)
- Wrapper _run_cron_tracked() marks done on completion
- New GET /api/crons/status?job_id=... returns {running, elapsed}
- New GET /api/crons/status returns all running jobs
Frontend:
- After 'Run Now', enters watch mode with 3s polling
- Shows running indicator (spinner + elapsed timer) in detail card
- Auto-detects running jobs when opening detail view
- Stops watch and refreshes output on job completion
- Cleanup on detail view switch
Note: True SSE streaming is not possible because the hermes-agent
scheduler writes output files only on completion. This polling
approach provides real-time status feedback within that constraint.
This commit is contained in:
1 parent
f2f7224b8d
commit
98ed2d804b
4 files changed
+133
-4
No files matched your search
+51
-2
@@ -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):
|
||||
|
||||
+1
-1
@@ -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를 재시작한 후 이 작업을 재개하세요.',
|
||||
|
||||
+75
-1
@@ -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 = `<span class="cron-watch-spinner"></span><span>${esc(t('cron_status_running'))}</span><span class="cron-watch-elapsed">0s</span>`;
|
||||
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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;}
|
||||
|
||||
Reference in new issue
Block a user