Loading jobs…
diff --git a/packages/app/src/page-refresh.ts b/packages/app/src/page-refresh.ts new file mode 100644 index 0000000..7f0e7c0 --- /dev/null +++ b/packages/app/src/page-refresh.ts @@ -0,0 +1,37 @@ +/** Shared browser orchestration. Completion-based timers prevent overlapping polls. */ +export const pageRefreshBrowserSource = ` +function pageRefresh(load,{delay=()=>3000,onError=()=>{}}={}){ + let timer,busy=false,stopped=false,failures=0; + const clear=()=>{clearTimeout(timer);timer=undefined}; + const schedule=ms=>{clear();if(!stopped&&!document.hidden&&ms!==null)timer=setTimeout(refresh,ms)}; + async function refresh(){ + if(stopped||document.hidden||busy)return; + clear();busy=true; + let next; + try{await load();failures=0;next=delay()} + catch(error){failures++;next=Math.min(30000,5000*2**(failures-1));onError(error)} + finally{busy=false;schedule(next)} + } + const resume=()=>{clear();if(!document.hidden)refresh()}; + const hide=()=>{stopped=true;clear()}; + const show=()=>{stopped=false;resume()}; + document.addEventListener('visibilitychange',resume); + window.addEventListener('focus',resume); + window.addEventListener('pagehide',hide); + window.addEventListener('pageshow',show); + refresh(); + return {refresh}; +} +// Avoid replacing unchanged content; retain disclosure and keyboard position on updates. +function replacePageContent(root,html,afterReplace=()=>{}){ + const open=[...root.querySelectorAll('details')].map(x=>x.open); + const focused=root.contains(document.activeElement)?document.activeElement:null; + const focusId=focused?.id,focusHref=focused?.getAttribute('href'),focusPage=focused?.dataset.runPage; + const summaryIndex=[...root.querySelectorAll('details>summary')].indexOf(focused); + root.innerHTML=html; + root.querySelectorAll('details').forEach((x,i)=>x.open=Boolean(open[i])); + afterReplace(); + const target=focusPage?[...root.querySelectorAll('[data-run-page]')].find(x=>x.dataset.runPage===focusPage):summaryIndex>=0?root.querySelectorAll('details>summary')[summaryIndex]:focusId?document.getElementById(focusId):focusHref?[...root.querySelectorAll('a')].find(x=>x.getAttribute('href')===focusHref):null; + target?.focus({preventScroll:true}); +} +`; diff --git a/packages/app/src/ui.ts b/packages/app/src/ui.ts index f8da0b7..938db28 100644 --- a/packages/app/src/ui.ts +++ b/packages/app/src/ui.ts @@ -1,6 +1,7 @@ /** Framework-light pages; Tailwind is compiled locally into public/styles.css. */ import { triggerContextCatalog } from "./trigger-context"; import { ansiToHtmlBrowserSource } from "./ansi"; +import { pageRefreshBrowserSource } from "./page-refresh"; import { tracePresentationBrowserSource } from "./trace-presentation"; type Viewer = { email: string } | null; const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); @@ -74,6 +75,7 @@ const card = "rounded-2xl border border-stone-200 bg-white shadow-sm dark:border const uiHelpers = ` const $=s=>document.querySelector(s),esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); ${ansiToHtmlBrowserSource} +${pageRefreshBrowserSource} const request=async(url,options)=>{const requestUrl=String(url).startsWith('/api/v1/runs?jobId=')&&!String(url).includes('limit=')?String(url)+'&limit=20':url,r=await fetch(requestUrl,options),body=await r.json().catch(()=>({}));if(r.status===401){location.href='/auth/linear';throw new Error('Unauthorized')}if(!r.ok)throw new Error(body.error?.message||body.error||'Request failed');return body}; const jobRunsPageSize=20,jobRunsState={page:1,cursors:[null],runs:null,loading:false}; const jobRunsMarkup=r=>r.items?.length?'
No runs yet.
'; @@ -90,7 +92,8 @@ const jobStatus=j=>j.runningCount>0?{label:'Running',classes:'bg-blue-500 ring-b export const jobsPage = (viewer: { email: string }) => shell("Jobs — Factorize", viewer, `Loading jobs…
Create a manual-only or automatically triggered job.
'+esc(j.runningCount)+'/'+esc(j.concurrencyLimit)+' Running
'+esc(e.message)+'
');`); +let jobsSnapshot,activeJobs=false; +async function load(){const jobs=await request('/api/v1/jobs');activeJobs=jobs.some(j=>j.runningCount>0);const snapshot=JSON.stringify(jobs);if(snapshot===jobsSnapshot)return;jobsSnapshot=snapshot;const root=$('#jobs');if(!jobs.length){root.innerHTML='Create a manual-only or automatically triggered job.
'+esc(j.runningCount)+'/'+esc(j.concurrencyLimit)+' Running
'+esc(e.message)+'
'}});`); export const jobPage = (viewer: { email: string }, jobId?: string) => shell(`${jobId ? "Edit" : "Create"} job — Factorize`, viewer, `