cronpan
reading crontab…
CP
cronpan v0.1
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.

Schedule–
Status–
Log file–
Running now–
Actions
Logs
Date:

Rename Job
⚠ Name will be cleared — the command will be used as the display name and the description line will be removed.
Confirm
Set Log File
Full path to an existing or future log file. cronpan will read it as-is.
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=`
`+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); }