reading crontab…
Cron Jobs

No cron jobs found

Your crontab appears to be empty.
Run crontab -e to add jobs.

Select a job to view details,
manage logging, and read logs.

Actions
Logs

        
let jobs=[], selectedId=null, selectedDate=null, availableDates=[]; let pendingAction=null, renameJobId=null, logpathJobId=null; let selectedIds=new Set(); let currentFilter='all'; // ── INIT ────────────────────────────────────────────── document.addEventListener('DOMContentLoaded',()=>{ // Timezone badge const offset = -new Date().getTimezoneOffset(); const sign = offset >= 0 ? '+' : '-'; const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'); const m = String(Math.abs(offset) % 60).padStart(2, '0'); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; document.getElementById('tz-label').textContent = `${tz} (UTC${sign}${h}:${m})`; // Click on jobs-panel background (not on a card) resets detail panel document.getElementById('jobs-panel').addEventListener('click', e => { if(!e.target.closest('.job-card') && selectedId !== null){ selectedId = null; document.getElementById('detail-empty').style.display = 'flex'; document.getElementById('detail-content').style.display = 'none'; document.querySelectorAll('.job-card').forEach(c => c.classList.remove('selected')); } }); loadJobs(); setInterval(()=>loadJobs(true),15000); }); function goHome(e){ e.preventDefault(); selectedId=null; document.getElementById('detail-empty').style.display='flex'; document.getElementById('detail-content').style.display='none'; document.querySelectorAll('.job-card').forEach(c=>c.classList.remove('selected')); history.pushState(null,'','/home'); return false; } // ── LOAD ────────────────────────────────────────────── async function loadJobs(silent=false){ if(!silent) document.getElementById('loading-overlay').classList.remove('hidden'); try{ const res=await fetch('/api/jobs'); const data=await res.json(); if(!data.ok) throw new Error(data.error); jobs=data.jobs; renderJobs(); if(selectedId!==null){ const job=jobs.find(j=>j.id===selectedId); if(job) renderDetail(job); } document.getElementById('last-refreshed').textContent=`refreshed ${new Date().toLocaleTimeString()}`; }catch(e){ showToast('Error: '+e.message,'err'); } finally{ document.getElementById('loading-overlay').classList.add('hidden'); } } function setFilter(f, el){ currentFilter=f; document.querySelectorAll('.filter-tab').forEach(t=>t.classList.remove('active')); el.classList.add('active'); renderJobs(); } function updateRenameWarning(){ const empty=document.getElementById('rename-input').value.trim()===''; document.getElementById('rename-warning').style.display=empty?'block':'none'; document.getElementById('rename-ok-btn').textContent=empty?'clear name':'rename'; } // ── RENDER JOBS LIST ────────────────────────────────── function renderJobs(){ const list=document.getElementById('jobs-list'); const all=jobs.filter(j=>j.status!=='deleted'); const visible=currentFilter==='active' ? all.filter(j=>j.status==='active') :currentFilter==='inactive'? all.filter(j=>j.status==='disabled') : all; document.getElementById('job-count').textContent=all.length+' jobs'; if(visible.length===0){ list.innerHTML=`
No ${currentFilter==='all'?'':''+currentFilter+' '}jobs found.
`; document.getElementById('empty-state').style.display='none'; updateBulkBar(); return; } document.getElementById('empty-state').style.display='none'; list.innerHTML=''; visible.forEach(job=>list.appendChild(makeJobCard(job))); selectedIds.forEach(id=>{ if(!visible.find(j=>j.id===id)) selectedIds.delete(id); }); updateBulkBar(); if(selectedId!==null&&!visible.find(j=>j.id===selectedId)){ selectedId=null; document.getElementById('detail-empty').style.display='flex'; document.getElementById('detail-content').style.display='none'; } } function makeJobCard(job){ const card=document.createElement('div'); card.className='job-card'+(job.id===selectedId?' selected':''); card.dataset.id=job.id; card.onclick=(e)=>{ if(!e.target.closest('.card-actions')&&!e.target.closest('.job-check-ui')&&!e.target.closest('.job-checkbox')) selectJob(job.id); }; const dotClass=job.running?'status-running':job.status==='active'?'status-active':'status-disabled'; const tags=[]; tags.push(`${escHtml(job.schedule)}`); if(job.status==='disabled') tags.push(`inactive`); if(job.running) tags.push(`● running`); if(job.readonly) tags.push(`system`); if(job.is_complex) tags.push(`⚠ complex`); else if(job.has_logging) tags.push(`● logging`); else if(!job.readonly) tags.push(`no logging`); const isActive=job.status==='active'; const toggleTip=isActive?'Deactivate':'Activate'; const toggleCls='cb-toggle'+(isActive?'':' is-disabled'); const toggleIcon=isActive ? `` : ``; const action=isActive?'disable':'enable'; const actionCls=isActive?'btn-deactivate':'btn-activate'; const actionLabel=isActive?'deactivate':'activate'; const actionMsg=isActive?'This will comment out the cron line.':'This will uncomment the cron line.'; const checked=selectedIds.has(job.id)?'checked':''; card.innerHTML=`
${escHtml(job.name)}
${escHtml(job.command)}
${tags.join('')}
${job.readonly ? '' : ` `}
`; return card; } // ── BULK SELECTION ──────────────────────────────────── function toggleSelect(id,checked){ if(checked) selectedIds.add(id); else selectedIds.delete(id); const lbl=document.querySelector(`label[for="chk-${id}"]`); if(lbl) lbl.classList.toggle('checked',checked); updateBulkBar(); } function updateBulkBar(){ const bar=document.getElementById('bulk-bar'); const count=document.getElementById('bulk-count'); if(selectedIds.size>0){ bar.classList.add('visible'); count.textContent=selectedIds.size+' selected'; } else { bar.classList.remove('visible'); } } function clearSelection(){ selectedIds.clear(); document.querySelectorAll('.job-check-ui').forEach(l=>l.classList.remove('checked')); document.querySelectorAll('.job-checkbox').forEach(cb=>cb.checked=false); updateBulkBar(); } function bulkAction(action){ const n=selectedIds.size; const label={enable:'Activate',disable:'Deactivate',delete:'Delete'}[action]; const msg=`${label} ${n} job${n>1?'s':''}?`; const body=action==='delete' ? 'Selected jobs will be marked as deleted.' : `Selected jobs will be ${action==='enable'?'activated':'deactivated'}.`; const cls=action==='enable'?'btn-activate':action==='disable'?'btn-deactivate':'btn-delete'; pendingAction={bulk:true,ids:[...selectedIds],action}; document.getElementById('confirm-title').textContent=msg; document.getElementById('confirm-body').textContent=body; const btn=document.getElementById('confirm-ok-btn'); btn.className='action-btn '+cls; btn.textContent=label.toLowerCase(); openModal('confirm-modal'); } // ── SELECT JOB (open detail) ────────────────────────── function selectJob(id){ selectedId=id; document.querySelectorAll('.job-card').forEach(c=>c.classList.toggle('selected',parseInt(c.dataset.id)===id)); const job=jobs.find(j=>j.id===id); if(job) renderDetail(job); } // ── RENDER DETAIL ───────────────────────────────────── function renderDetail(job){ document.getElementById('detail-empty').style.display='none'; document.getElementById('detail-content').style.display='flex'; // Section 1 document.getElementById('detail-job-name').textContent=job.name; const cmdEl=document.getElementById('detail-job-command'); cmdEl.textContent=job.command; cmdEl.dataset.full=job.command; // Section 2 document.getElementById('info-schedule').textContent=job.schedule||'–'; document.getElementById('info-status').textContent=job.running?'● running':job.status==='active'?'active':'inactive'; document.getElementById('info-running').textContent=job.running?'yes':'no'; document.getElementById('info-logfile').textContent='–'; // Section 3: actions const actions=document.getElementById('detail-actions'); const actionsSection=document.getElementById('detail-actions-section'); actions.innerHTML=''; if(job.readonly){ actionsSection.style.display='none'; } else { actionsSection.style.display=''; if(job.status!=='deleted'){ if(job.status==='disabled'){ addBtn(actions,'activate','btn-activate',()=>askConfirm(job.id,'enable','Activate job?','This will uncomment the cron line.','btn-activate','activate'), ''); } else { addBtn(actions,'deactivate','btn-deactivate',()=>askConfirm(job.id,'disable','Deactivate job?','This will comment out the cron line.','btn-deactivate','deactivate'), ''); } addBtn(actions,'rename','btn-rename',()=>promptRename(job), ''); addBtn(actions,'delete','btn-delete',()=>askConfirm(job.id,'delete','Delete job?','The cron line will be marked as deleted.','btn-delete','delete'), ''); } } // Section 3b: log controls const logControls=document.getElementById('detail-log-controls'); const logActions=document.getElementById('log-actions'); logActions.innerHTML=''; if(!job.readonly&&job.status!=='deleted'){ if(!job.is_complex){ if(!job.has_logging){ addBtn(logActions,'enable logging','btn-log-add',()=>askConfirm(job.id,'add-logging','Enable logging?','Output will be captured to ~/.cronlogs/.','btn-log-add','enable'), ''); } else { addBtn(logActions,'disable logging','btn-log-remove',()=>askConfirm(job.id,'remove-logging','Disable logging?','The logger pipe will be removed from the cron line.','btn-delete','disable'), ''); if(job.log_path){ addBtn(logActions,'clear logs','btn-clear-logs',()=>askConfirm(job.id,'clear-logs','Clear all logs?',`All log files for "${job.name}" will be permanently deleted.`,'btn-delete','clear'), ''); } } } if(!job.has_logging||job.is_complex){ addBtn(logActions,'set log file','btn-log-add',()=>promptLogpath(job), ''); } else if(job.is_custom_log){ addBtn(logActions,'remove log file','btn-log-remove',()=>askConfirm(job.id,'remove-logpath','Remove log file?','The #[LOGPATH] reference will be removed from the cron line.','btn-delete','remove'), ''); } logControls.style.display='block'; } else { logControls.style.display='none'; } // Warnings const warnings=document.getElementById('detail-warnings'); warnings.innerHTML=''; if(job.readonly) warnings.innerHTML+=`
System job from ${escHtml(job.source)} — read only.
`; else if(job.is_complex) warnings.innerHTML+=`
Complex entry — logging cannot be added automatically.
`; else if(job.has_other_redirect&&!job.has_logging) warnings.innerHTML+=`
Job already redirects output. Log reading not available in v1.
`; renderLogs(job); } // ── RENDER LOGS ─────────────────────────────────────── async function renderLogs(job,date=null){ const pre=document.getElementById('logs-pre'); const noMsg=document.getElementById('no-logs-msg'); const pickerWrap=document.getElementById('date-picker-wrap'); const picker=document.getElementById('log-date-picker'); const logfileEl=document.getElementById('info-logfile'); pre.innerHTML=''; pre.style.display='none'; noMsg.textContent=''; pickerWrap.style.display='none'; // Reset log file to base path if(job.log_path){ const full=shortPath(job.log_path); const dirName=full.split('/').pop(); logfileEl.innerHTML=`${escHtml(dirName)}${escHtml(full)}`; } else { logfileEl.textContent='none'; } if(!job.has_logging){ noMsg.textContent='Logging not enabled. Click "enable logging" to start capturing output.'; return; } if(!job.log_accessible&&job.log_inaccessible_reason){ noMsg.textContent=job.log_inaccessible_reason; return; } function updateLogfileLabel(d){ if(!job.log_path) return; const full=shortPath(job.log_path); const dirName=full.split('/').pop(); const display=job.log_is_dir && d ? `${dirName}/${d}.log` : dirName; const fullFile=job.log_is_dir && d ? `${full}/${d}.log` : full; logfileEl.innerHTML=`${escHtml(display)}${escHtml(fullFile)}`; } if(job.log_is_dir){ try{ const dres=await fetch(`/api/jobs/${job.id}/log-dates`); const ddata=await dres.json(); if(ddata.ok&&ddata.dates.length>0){ availableDates=ddata.dates; if(!date) date=ddata.dates[0]; selectedDate=date; const iso=`${date.slice(0,4)}-${date.slice(4,6)}-${date.slice(6)}`; picker.value=iso; const minD=ddata.dates[ddata.dates.length-1], maxD=ddata.dates[0]; picker.min=`${minD.slice(0,4)}-${minD.slice(4,6)}-${minD.slice(6)}`; picker.max=`${maxD.slice(0,4)}-${maxD.slice(4,6)}-${maxD.slice(6)}`; updateLogfileLabel(date); picker.onchange=()=>{ const val=picker.value.replace(/-/g,''); updateLogfileLabel(val); if(availableDates.includes(val)) renderLogs(job,val); else{ noMsg.textContent=`No logs for ${picker.value}.`; pre.innerHTML=''; pre.style.display='none'; } }; pickerWrap.style.display='flex'; } }catch(e){} } noMsg.textContent='Loading logs…'; try{ const url=date?`/api/jobs/${job.id}/logs?date=${date}`:`/api/jobs/${job.id}/logs`; const res=await fetch(url); const data=await res.json(); if(!data.ok){ noMsg.textContent=data.error; return; } noMsg.textContent=''; pre.innerHTML=colorizeLog(data.logs); pre.style.display='block'; document.getElementById('logs-body').scrollTop=999999; }catch(e){ noMsg.textContent='Failed to load logs: '+e.message; } } function colorizeLog(text){ if(!text) return 'No output yet.'; return text.split('\n').map(line=>{ const esc=escHtml(line); if(line.startsWith('▶')) return `${esc}`; if(line.startsWith('■')&&line.includes('EXIT:0')) return `${esc}`; if(line.startsWith('■')) return `${esc}`; const m=line.match(/^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])\s*(.*)/); if(m) return `${escHtml(m[1])} ${escHtml(m[2])}`; return `${esc}`; }).join('\n'); } // ── CONFIRM MODAL ───────────────────────────────────── function askConfirm(jobId,action,title,body,btnCls,btnLabel){ pendingAction={jobId,action}; document.getElementById('confirm-title').textContent=title; document.getElementById('confirm-body').textContent=body; const btn=document.getElementById('confirm-ok-btn'); btn.className='action-btn '+btnCls; btn.textContent=btnLabel; openModal('confirm-modal'); } async function confirmAction(){ if(!pendingAction) return; const pa=pendingAction; pendingAction=null; closeModal('confirm-modal'); if(pa.bulk){ // run actions sequentially for(const id of pa.ids){ try{ const res=await fetch(`/api/jobs/${id}/${pa.action}`,{method:'POST'}); const data=await res.json(); if(!data.ok) console.warn(`Failed for job ${id}:`,data.error); }catch(e){ console.warn(e); } } const label={enable:'Activated',disable:'Deactivated',delete:'Deleted'}[pa.action]; showToast(`${label} ${pa.ids.length} job${pa.ids.length>1?'s':''}`, 'ok'); clearSelection(); await loadJobs(true); if(selectedId!==null){ const job=jobs.find(j=>j.id===selectedId); if(job) renderDetail(job); } } else { await doAction(pa.jobId,pa.action); } } // ── RENAME ──────────────────────────────────────────── function promptRename(job){ if(!job) return; renameJobId=job.id; const input=document.getElementById('rename-input'); input.value=job.name; document.getElementById('rename-warning').style.display='none'; document.getElementById('rename-ok-btn').textContent='rename'; openModal('rename-modal'); setTimeout(()=>{ input.focus(); input.select(); },80); } async function confirmRename(){ const name=document.getElementById('rename-input').value.trim(); // empty = clear if(renameJobId===null) return; const id=renameJobId; closeModal('rename-modal'); try{ const res=await fetch(`/api/jobs/${id}/rename`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); const data=await res.json(); if(!data.ok) throw new Error(data.error); showToast(name?'Renamed successfully':'Name cleared','ok'); await loadJobs(true); const job=jobs.find(j=>j.id===id); if(job) renderDetail(job); }catch(e){ showToast('Error: '+e.message,'err'); } } // ── LOGPATH ─────────────────────────────────────────── function promptLogpath(job){ logpathJobId=job.id; const input=document.getElementById('logpath-input'); input.value=(job.is_custom_log&&job.log_path)?job.log_path:''; openModal('logpath-modal'); setTimeout(()=>{ input.focus(); input.select(); },80); } async function confirmLogpath(){ const path=document.getElementById('logpath-input').value.trim(); if(!path) return; closeModal('logpath-modal'); try{ const res=await fetch(`/api/jobs/${logpathJobId}/set-logpath`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({log_path:path})}); const data=await res.json(); if(data.ok){ showToast('Log file saved','ok'); loadJobs(true); } else showToast('Error: '+data.error,'err'); }catch(e){ showToast('Error: '+e.message,'err'); } } // ── ACTIONS ─────────────────────────────────────────── async function doAction(jobId,action){ try{ const res=await fetch(`/api/jobs/${jobId}/${action}`,{method:'POST'}); const data=await res.json(); if(!data.ok) throw new Error(data.error); const labels={enable:'Activated',disable:'Deactivated',delete:'Deleted','add-logging':'Logging enabled','remove-logging':'Logging disabled','clear-logs':'Logs cleared','remove-logpath':'Log file removed'}; showToast((labels[action]||action)+' successfully','ok'); await loadJobs(true); const job=jobs.find(j=>j.id===jobId); if(job){ renderDetail(job); } }catch(e){ showToast('Error: '+e.message,'err'); } } // ── COPY HELPERS ────────────────────────────────────── function copyCommand(el){ const text=el.dataset.full||el.textContent; copyText(text,el); } function copyText(text,el){ navigator.clipboard.writeText(text).then(()=>{ showToast('Copied!','ok'); }).catch(()=>{ showToast('Copy failed','err'); }); } // ── MODAL HELPERS ───────────────────────────────────── function openModal(id){ document.getElementById(id).classList.add('open'); } function closeModal(id){ document.getElementById(id).classList.remove('open'); } document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeModal('confirm-modal'); closeModal('rename-modal'); closeModal('logpath-modal'); }}); document.querySelectorAll('.modal-overlay').forEach(overlay=>{ overlay.addEventListener('click',e=>{ if(e.target===overlay) closeModal(overlay.id); }); }); // ── MISC ────────────────────────────────────────────── function addBtn(container,label,cls,fn,icon){ const btn=document.createElement('button'); btn.className='action-btn '+cls; if(icon) btn.innerHTML=`${icon}`+escHtml(label); else btn.textContent=label; btn.onclick=fn; container.appendChild(btn); } function shortPath(p){ if(!p) return ''; return p.replace(/\/root|\/home\/[^/]+|\/Users\/[^/]+/,'~'); } function escHtml(s){ if(!s) return ''; return s.replace(/&/g,'&').replace(//g,'>'); } function escAttr(s){ if(!s) return ''; return s.replace(/"/g,'"').replace(/'/g,"\\'"); } let toastTimer; function showToast(msg,type=''){ const t=document.getElementById('toast'); t.textContent=msg; t.className='show '+type; clearTimeout(toastTimer); toastTimer=setTimeout(()=>{t.className='';},2800); }