1/** 2 * Copyright (c) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you 5 * may not use this file except in compliance with the License. You may 6 * obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 13 * implied. See the License for the specific language governing 14 * permissions and limitations under the License. 15 */ 16 17'use strict'; 18 19// If you add or remove job types, do not forget to fix the colspans below. 20const JOB_TYPES = [ 21 { id: 'linux-gcc8-x86_64-release', label: 'rel' }, 22 { id: 'linux-clang-x86_64-debug', label: 'dbg' }, 23 { id: 'linux-clang-x86_64-tsan', label: 'tsan' }, 24 { id: 'linux-clang-x86_64-msan', label: 'msan' }, 25 { id: 'linux-clang-x86_64-asan_lsan', label: '{a,l}san' }, 26 { id: 'linux-clang-x86-release', label: 'x86 rel' }, 27 { id: 'linux-clang-x86_64-libfuzzer', label: 'fuzzer' }, 28 { id: 'linux-clang-x86_64-bazel', label: 'bazel' }, 29 { id: 'ui-clang-x86_64-release', label: 'rel' }, 30 { id: 'android-clang-arm-release', label: 'rel' }, 31]; 32 33const STATS_LINK = 34 'https://app.google.stackdriver.com/dashboards/5008687313278081798?project=perfetto-ci'; 35 36const state = { 37 // An array of recent CL objects retrieved from Gerrit. 38 gerritCls: [], 39 40 // A map of sha1 -> Gerrit commit object. 41 // See 42 // https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-commit 43 gerritCommits: {}, 44 45 // A map of git-log ranges to commit objects: 46 // 'dead..beef' -> [commit1, 2] 47 gerritLogs: {}, 48 49 // Maps 'cls/1234-1' or 'branches/xxxx' -> array of job ids. 50 dbJobSets: {}, 51 52 // Maps 'jobId' -> DB job object, as perf /ci/jobs/jobID. 53 // A jobId looks like 20190702143507-1008614-9-android-clang-arm. 54 dbJobs: {}, 55 56 // Maps 'worker id' -> DB wokrker object, as per /ci/workers. 57 dbWorker: {}, 58 59 // Maps 'main-YYMMDD' -> DB branch object, as per /ci/branches/xxx. 60 dbBranches: {}, 61 getBranchKeys: () => Object.keys(state.dbBranches).sort().reverse(), 62 63 // Maps 'CL number' -> true|false. Retains the collapsed/expanded information 64 // for each row in the CLs table. 65 expandCl: {}, 66 67 postsubmitShown: 3, 68 69 // Lines that will be appended to the terminal on the next redraw() cycle. 70 termLines: [ 71 'Hover a CL icon to see the log tail.', 'Click on it to load the full log.' 72 ], 73 termJobId: undefined, // The job id currently being shown by the terminal. 74 termClear: false, // If true the next redraw will clear the terminal. 75 redrawPending: false, 76 77 // State for the Jobs page. These are arrays of job ids. 78 jobsQueued: [], 79 jobsRunning: [], 80 jobsRecent: [], 81 82 // Firebase DB listeners (the objects returned by the .ref() operator). 83 realTimeLogRef: undefined, // Ref for the real-time log streaming. 84 workersRef: undefined, 85 jobsRunningRef: undefined, 86 jobsQueuedRef: undefined, 87 jobsRecentRef: undefined, 88 clRefs: {}, // '1234-1' -> Ref subscribed to updates on the given cl. 89 jobRefs: {}, // '....-arm-asan' -> Ref subscribed updates on the given job. 90 branchRefs: {} // 'main' -> Ref subscribed updates on the given branch. 91}; 92 93let term = undefined; 94let fitAddon = undefined; 95let searchAddon = undefined; 96 97function main() { 98 firebase.initializeApp({ databaseURL: cfg.DB_ROOT }); 99 100 m.route(document.body, '/cls', { 101 '/cls': CLsPageRenderer, 102 '/cls/:cl': CLsPageRenderer, 103 '/logs/:jobId': LogsPageRenderer, 104 '/jobs': JobsPageRenderer, 105 '/jobs/:jobId': JobsPageRenderer, 106 }); 107 108 setInterval(fetchGerritCLs, 15000); 109 fetchGerritCLs(); 110 fetchCIStatusForBranch('main'); 111} 112 113// ----------------------------------------------------------------------------- 114// Rendering functions 115// ----------------------------------------------------------------------------- 116 117function renderHeader() { 118 const active = id => m.route.get().startsWith(`/${id}`) ? '.active' : ''; 119 const logUrl = 'https://goto.google.com/perfetto-ci-logs-'; 120 const docsUrl = 121 'https://perfetto.dev/docs/design-docs/continuous-integration'; 122 return m( 123 'header', m('a[href=/#!/cls]', m('h1', 'Perfetto ', m('span', 'CI'))), 124 m( 125 'nav', 126 m(`div${active('cls')}`, m('a[href=/#!/cls]', 'CLs')), 127 m(`div${active('jobs')}`, m('a[href=/#!/jobs]', 'Jobs')), 128 m(`div${active('stats')}`, 129 m(`a[href=${STATS_LINK}][target=_blank]`, 'Stats')), 130 m(`div`, m(`a[href=${docsUrl}][target=_blank]`, 'Docs')), 131 m( 132 `div.logs`, 133 'Logs', 134 m('div', 135 m(`a[href=${logUrl}controller][target=_blank]`, 'Controller')), 136 m('div', m(`a[href=${logUrl}workers][target=_blank]`, 'Workers')), 137 m('div', 138 m(`a[href=${logUrl}frontend][target=_blank]`, 'Frontend')), 139 ), 140 )); 141} 142 143var CLsPageRenderer = { 144 view: function (vnode) { 145 const allCols = 4 + JOB_TYPES.length; 146 const postsubmitHeader = m('tr', 147 m(`td.header[colspan=${allCols}]`, 'Post-submit') 148 ); 149 150 const postsubmitLoadMore = m('tr', 151 m(`td[colspan=${allCols}]`, 152 m('a[href=#]', 153 { onclick: () => state.postsubmitShown += 10 }, 154 'Load more' 155 ) 156 ) 157 ); 158 159 const presubmitHeader = m('tr', 160 m(`td.header[colspan=${allCols}]`, 'Pre-submit') 161 ); 162 163 let branchRows = []; 164 const branchKeys = state.getBranchKeys(); 165 for (let i = 0; i < branchKeys.length && i < state.postsubmitShown; i++) { 166 const rowsForBranch = renderPostsubmitRow(branchKeys[i]); 167 branchRows = branchRows.concat(rowsForBranch); 168 } 169 170 let clRows = []; 171 for (const gerritCl of state.gerritCls) { 172 if (vnode.attrs.cl && gerritCl.num != vnode.attrs.cl) continue; 173 clRows = clRows.concat(renderCLRow(gerritCl)); 174 } 175 176 let footer = []; 177 if (vnode.attrs.cl) { 178 footer = m('footer', 179 `Showing only CL ${vnode.attrs.cl} - `, 180 m(`a[href=#!/cls]`, 'Click here to see all CLs') 181 ); 182 } 183 184 return [ 185 renderHeader(), 186 m('main#cls', 187 m('div.table-scrolling-container', 188 m('table.main-table', 189 m('thead', 190 m('tr', 191 m('td[rowspan=4]', 'Subject'), 192 m('td[rowspan=4]', 'Status'), 193 m('td[rowspan=4]', 'Owner'), 194 m('td[rowspan=4]', 'Updated'), 195 m('td[colspan=10]', 'Bots'), 196 ), 197 m('tr', 198 m('td[colspan=9]', 'linux'), 199 m('td[colspan=2]', 'android'), 200 ), 201 m('tr', 202 m('td', 'gcc8'), 203 m('td[colspan=7]', 'clang'), 204 m('td[colspan=1]', 'ui'), 205 m('td[colspan=1]', 'clang-arm'), 206 ), 207 m('tr#cls_header', 208 JOB_TYPES.map(job => m(`td#${job.id}`, job.label)) 209 ), 210 ), 211 m('tbody', 212 postsubmitHeader, 213 branchRows, 214 postsubmitLoadMore, 215 presubmitHeader, 216 clRows, 217 ) 218 ), 219 footer, 220 ), 221 m(TermRenderer), 222 ), 223 ]; 224 } 225}; 226 227 228function getLastUpdate(lastUpdate) { 229 const lastUpdateMins = Math.ceil((Date.now() - lastUpdate) / 60000); 230 if (lastUpdateMins < 60) 231 return lastUpdateMins + ' mins ago'; 232 if (lastUpdateMins < 60 * 24) 233 return Math.ceil(lastUpdateMins / 60) + ' hours ago'; 234 return lastUpdate.toISOString().substr(0, 10); 235} 236 237function renderCLRow(cl) { 238 const expanded = !!state.expandCl[cl.num]; 239 const toggleExpand = () => { 240 state.expandCl[cl.num] ^= 1; 241 fetchCIJobsForAllPatchsetOfCL(cl.num); 242 } 243 const rows = []; 244 245 // Create the row for the latest patchset (as fetched by Gerrit). 246 rows.push(m(`tr.${cl.status}`, 247 m('td', 248 m(`i.material-icons.expand${expanded ? '.expanded' : ''}`, 249 { onclick: toggleExpand }, 250 'arrow_right' 251 ), 252 m(`a[href=${cfg.GERRIT_REVIEW_URL}/+/${cl.num}/${cl.psNum}]`, 253 `${cl.subject}`, m('span.ps', `#${cl.psNum}`)) 254 ), 255 m('td', cl.status), 256 m('td', stripEmail(cl.owner || '')), 257 m('td', getLastUpdate(cl.lastUpdate)), 258 JOB_TYPES.map(x => renderClJobCell(`cls/${cl.num}-${cl.psNum}`, x.id)) 259 )); 260 261 // If the usere clicked on the expand button, show also the other patchsets 262 // present in the CI DB. 263 for (let psNum = cl.psNum; expanded && psNum > 0; psNum--) { 264 const src = `cls/${cl.num}-${psNum}`; 265 const jobs = state.dbJobSets[src]; 266 if (!jobs) continue; 267 rows.push(m(`tr.nested`, 268 m('td', 269 m(`a[href=${cfg.GERRIT_REVIEW_URL}/+/${cl.num}/${psNum}]`, 270 ' Patchset', m('span.ps', `#${psNum}`)) 271 ), 272 m('td', ''), 273 m('td', ''), 274 m('td', ''), 275 JOB_TYPES.map(x => renderClJobCell(src, x.id)) 276 )); 277 } 278 279 return rows; 280} 281 282function renderPostsubmitRow(key) { 283 const branch = state.dbBranches[key]; 284 console.assert(branch !== undefined); 285 const subject = branch.subject; 286 let rows = []; 287 rows.push(m(`tr`, 288 m('td', 289 m(`a[href=${cfg.REPO_URL}/+/${branch.rev}]`, 290 subject, m('span.ps', `#${branch.rev.substr(0, 8)}`) 291 ) 292 ), 293 m('td', ''), 294 m('td', stripEmail(branch.author)), 295 m('td', getLastUpdate(new Date(branch.time_committed))), 296 JOB_TYPES.map(x => renderClJobCell(`branches/${key}`, x.id)) 297 )); 298 299 300 const allKeys = state.getBranchKeys(); 301 const curIdx = allKeys.indexOf(key); 302 if (curIdx >= 0 && curIdx < allKeys.length - 1) { 303 const nextKey = allKeys[curIdx + 1]; 304 const range = `${state.dbBranches[nextKey].rev}..${branch.rev}`; 305 const logs = (state.gerritLogs[range] || []).slice(1); 306 for (const log of logs) { 307 if (log.parents.length < 2) 308 continue; // Show only merge commits. 309 rows.push( 310 m('tr.nested', 311 m('td', 312 m(`a[href=${cfg.REPO_URL}/+/${log.commit}]`, 313 log.message.split('\n')[0], 314 m('span.ps', `#${log.commit.substr(0, 8)}`) 315 ) 316 ), 317 m('td', ''), 318 m('td', stripEmail(log.author.email)), 319 m('td', getLastUpdate(parseGerritTime(log.committer.time))), 320 m(`td[colspan=${JOB_TYPES.length}]`, 321 'No post-submit was run for this revision' 322 ), 323 ) 324 ); 325 } 326 } 327 328 return rows; 329} 330 331function renderJobLink(jobId, jobStatus) { 332 const ICON_MAP = { 333 'COMPLETED': 'check_circle', 334 'STARTED': 'hourglass_full', 335 'QUEUED': 'schedule', 336 'FAILED': 'bug_report', 337 'CANCELLED': 'cancel', 338 'INTERRUPTED': 'cancel', 339 'TIMED_OUT': 'notification_important', 340 }; 341 const icon = ICON_MAP[jobStatus] || 'clear'; 342 const eventHandlers = jobId ? { onmouseover: () => showLogTail(jobId) } : {}; 343 const logUrl = jobId ? `#!/logs/${jobId}` : '#'; 344 return m(`a.${jobStatus}[href=${logUrl}][title=${jobStatus}]`, 345 eventHandlers, 346 m(`i.material-icons`, icon) 347 ); 348} 349 350function renderClJobCell(src, jobType) { 351 let jobStatus = 'UNKNOWN'; 352 let jobId = undefined; 353 354 // To begin with check that the given CL/PS is present in the DB (the 355 // AppEngine cron job might have not seen that at all yet). 356 // If it is, find the global job id for the given jobType for the passed CL. 357 for (const id of (state.dbJobSets[src] || [])) { 358 const job = state.dbJobs[id]; 359 if (job !== undefined && job.type == jobType) { 360 // We found the job object that corresponds to jobType for the given CL. 361 jobStatus = job.status; 362 jobId = id; 363 } 364 } 365 return m('td.job', renderJobLink(jobId, jobStatus)); 366} 367 368const TermRenderer = { 369 oncreate: function(vnode) { 370 console.log('Creating terminal object'); 371 fitAddon = new FitAddon.FitAddon(); 372 searchAddon = new SearchAddon.SearchAddon(); 373 term = new Terminal({ 374 rows: 6, 375 fontFamily: 'monospace', 376 fontSize: 12, 377 scrollback: 100000, 378 disableStdin: true, 379 }); 380 term.loadAddon(fitAddon); 381 term.loadAddon(searchAddon); 382 term.open(vnode.dom); 383 fitAddon.fit(); 384 if (vnode.attrs.focused) 385 term.focus(); 386 }, 387 onremove: function(vnode) { 388 term.dispose(); 389 fitAddon.dispose(); 390 searchAddon.dispose(); 391 }, 392 onupdate: function(vnode) { 393 fitAddon.fit(); 394 if (state.termClear) { 395 term.clear(); 396 state.termClear = false; 397 } 398 for (const line of state.termLines) { 399 term.write(line + '\r\n'); 400 } 401 state.termLines = []; 402 }, 403 view: function() { 404 return m('.term-container', 405 { 406 onkeydown: (e) => { 407 if (e.key === 'f' && (e.ctrlKey || e.metaKey)) { 408 document.querySelector('.term-search').select(); 409 e.preventDefault(); 410 } 411 } 412 }, 413 m('input[type=text][placeholder=search and press Enter].term-search', { 414 onkeydown: (e) => { 415 if (e.key !== 'Enter') return; 416 if (e.shiftKey) { 417 searchAddon.findNext(e.target.value); 418 } else { 419 searchAddon.findPrevious(e.target.value); 420 } 421 e.stopPropagation(); 422 e.preventDefault(); 423 } 424 }) 425 ); 426 } 427}; 428 429const LogsPageRenderer = { 430 oncreate: function (vnode) { 431 showFullLog(vnode.attrs.jobId); 432 }, 433 view: function () { 434 return [ 435 renderHeader(), 436 m(TermRenderer, { focused: true }) 437 ]; 438 } 439} 440 441const JobsPageRenderer = { 442 oncreate: function (vnode) { 443 fetchRecentJobsStatus(); 444 fetchWorkers(); 445 }, 446 447 createWorkerTable: function () { 448 const makeWokerRow = workerId => { 449 const worker = state.dbWorker[workerId]; 450 if (worker.status === 'TERMINATED') return []; 451 return m('tr', 452 m('td', worker.host), 453 m('td', workerId), 454 m('td', worker.status), 455 m('td', getLastUpdate(new Date(worker.last_update))), 456 m('td', m(`a[href=#!/jobs/${worker.job_id}]`, worker.job_id)), 457 ); 458 }; 459 return m('table.main-table', 460 m('thead', 461 m('tr', m('td[colspan=5]', 'Workers')), 462 m('tr', 463 m('td', 'Host'), 464 m('td', 'Worker'), 465 m('td', 'Status'), 466 m('td', 'Last ping'), 467 m('td', 'Job'), 468 ) 469 ), 470 m('tbody', Object.keys(state.dbWorker).map(makeWokerRow)) 471 ); 472 }, 473 474 createJobsTable: function (vnode, title, jobIds) { 475 const tStr = function (tStart, tEnd) { 476 return new Date(tEnd - tStart).toUTCString().substr(17, 9); 477 }; 478 479 const makeJobRow = function (jobId) { 480 const job = state.dbJobs[jobId] || {}; 481 let cols = [ 482 m('td.job.align-left', 483 renderJobLink(jobId, job ? job.status : undefined), 484 m(`span.status.${job.status}`, job.status) 485 ) 486 ]; 487 if (job) { 488 const tQ = Date.parse(job.time_queued); 489 const tS = Date.parse(job.time_started); 490 const tE = Date.parse(job.time_ended) || Date.now(); 491 let cell = m(''); 492 if (job.src === undefined) { 493 cell = '?'; 494 } else if (job.src.startsWith('cls/')) { 495 const cl_and_ps = job.src.substr(4).replace('-', '/'); 496 const href = `${cfg.GERRIT_REVIEW_URL}/+/${cl_and_ps}`; 497 cell = m(`a[href=${href}][target=_blank]`, cl_and_ps); 498 } else if (job.src.startsWith('branches/')) { 499 cell = job.src.substr(9).split('-')[0] 500 } 501 cols.push(m('td', cell)); 502 cols.push(m('td', `${job.type}`)); 503 cols.push(m('td', `${job.worker || ''}`)); 504 cols.push(m('td', `${job.time_queued}`)); 505 cols.push(m(`td[title=Start ${job.time_started}]`, `${tStr(tQ, tS)}`)); 506 cols.push(m(`td[title=End ${job.time_ended}]`, `${tStr(tS, tE)}`)); 507 } else { 508 cols.push(m('td[colspan=6]', jobId)); 509 } 510 return m(`tr${vnode.attrs.jobId === jobId ? '.selected' : ''}`, cols) 511 }; 512 513 return m('table.main-table', 514 m('thead', 515 m('tr', m('td[colspan=7]', title)), 516 517 m('tr', 518 m('td', 'Status'), 519 m('td', 'CL'), 520 m('td', 'Type'), 521 m('td', 'Worker'), 522 m('td', 'T queued'), 523 m('td', 'Queue time'), 524 m('td', 'Run time'), 525 ) 526 ), 527 m('tbody', jobIds.map(makeJobRow)) 528 ); 529 }, 530 531 view: function (vnode) { 532 return [ 533 renderHeader(), 534 m('main', 535 m('.jobs-list', 536 this.createWorkerTable(), 537 this.createJobsTable(vnode, 'Queued + Running jobs', 538 state.jobsRunning.concat(state.jobsQueued)), 539 this.createJobsTable(vnode, 'Last 100 jobs', state.jobsRecent), 540 ), 541 ) 542 ]; 543 } 544}; 545 546// ----------------------------------------------------------------------------- 547// Business logic (handles fetching from Gerrit and Firebase DB). 548// ----------------------------------------------------------------------------- 549 550function parseGerritTime(str) { 551 // Gerrit timestamps are UTC (as per public docs) but obviously they are not 552 // encoded in ISO format. 553 return new Date(`${str} UTC`); 554} 555 556function stripEmail(email) { 557 return email.replace('@google.com', '@'); 558} 559 560// Fetches the list of CLs from gerrit and updates the state. 561async function fetchGerritCLs() { 562 console.log('Fetching CL list from Gerrit'); 563 let uri = '/gerrit/changes/?-age:7days'; 564 uri += '+-is:abandoned+branch:main&o=DETAILED_ACCOUNTS&o=CURRENT_REVISION'; 565 const response = await fetch(uri); 566 state.gerritCls = []; 567 if (response.status !== 200) { 568 setTimeout(fetchGerritCLs, 3000); // Retry. 569 return; 570 } 571 572 const json = (await response.text()); 573 const cls = []; 574 for (const e of JSON.parse(json)) { 575 const revHash = Object.keys(e.revisions)[0]; 576 const cl = { 577 subject: e.subject, 578 status: e.status, 579 num: e._number, 580 revHash: revHash, 581 psNum: e.revisions[revHash]._number, 582 lastUpdate: parseGerritTime(e.updated), 583 owner: e.owner.email, 584 }; 585 cls.push(cl); 586 fetchCIJobsForCLOrBranch(`cls/${cl.num}-${cl.psNum}`); 587 } 588 state.gerritCls = cls; 589 scheduleRedraw(); 590} 591 592async function fetchGerritCommit(sha1) { 593 const response = await fetch(`/gerrit/commits/${sha1}`); 594 console.assert(response.status === 200); 595 const json = (await response.text()); 596 state.gerritCommits[sha1] = JSON.parse(json); 597 scheduleRedraw(); 598} 599 600async function fetchGerritLog(first, second) { 601 const range = `${first}..${second}`; 602 const response = await fetch(`/gerrit/log/${range}`); 603 if (response.status !== 200) return; 604 const json = await response.text(); 605 state.gerritLogs[range] = JSON.parse(json).log; 606 scheduleRedraw(); 607} 608 609// Retrieves the status of a given (CL, PS) in the DB. 610function fetchCIJobsForCLOrBranch(src) { 611 if (src in state.clRefs) return; // Aslready have a listener for this key. 612 const ref = firebase.database().ref(`/ci/${src}`); 613 state.clRefs[src] = ref; 614 ref.on('value', (e) => { 615 const obj = e.val(); 616 if (!obj) return; 617 state.dbJobSets[src] = Object.keys(obj.jobs); 618 for (var jobId of state.dbJobSets[src]) { 619 fetchCIStatusForJob(jobId); 620 } 621 scheduleRedraw(); 622 }); 623} 624 625function fetchCIJobsForAllPatchsetOfCL(cl) { 626 let ref = firebase.database().ref('/ci/cls').orderByKey(); 627 ref = ref.startAt(`${cl}-0`).endAt(`${cl}-~`); 628 ref.once('value', (e) => { 629 const patchsets = e.val() || {}; 630 for (const clAndPs in patchsets) { 631 const jobs = Object.keys(patchsets[clAndPs].jobs); 632 state.dbJobSets[`cls/${clAndPs}`] = jobs; 633 for (var jobId of jobs) { 634 fetchCIStatusForJob(jobId); 635 } 636 } 637 scheduleRedraw(); 638 }); 639} 640 641function fetchCIStatusForJob(jobId) { 642 if (jobId in state.jobRefs) return; // Already have a listener for this key. 643 const ref = firebase.database().ref(`/ci/jobs/${jobId}`); 644 state.jobRefs[jobId] = ref; 645 ref.on('value', (e) => { 646 if (e.val()) state.dbJobs[jobId] = e.val(); 647 scheduleRedraw(); 648 }); 649} 650 651function fetchCIStatusForBranch(branch) { 652 if (branch in state.branchRefs) return; // Already have a listener. 653 const db = firebase.database(); 654 const ref = db.ref('/ci/branches') 655 .orderByKey() 656 .startAt('main') 657 .endAt('maio') 658 .limitToLast(20); 659 state.branchRefs[branch] = ref; 660 ref.on('value', (e) => { 661 const resp = e.val(); 662 if (!resp) return; 663 // key looks like 'main-YYYYMMDDHHMMSS', where YMD is the commit datetime. 664 // Iterate in most-recent-first order. 665 const keys = Object.keys(resp).sort().reverse(); 666 for (let i = 0; i < keys.length; i++) { 667 const key = keys[i]; 668 const branchInfo = resp[key]; 669 state.dbBranches[key] = branchInfo; 670 fetchCIJobsForCLOrBranch(`branches/${key}`); 671 if (i < keys.length - 1) { 672 fetchGerritLog(resp[keys[i + 1]].rev, branchInfo.rev); 673 } 674 } 675 scheduleRedraw(); 676 }); 677} 678 679function fetchWorkers() { 680 if (state.workersRef !== undefined) return; // Aslready have a listener. 681 const ref = firebase.database().ref('/ci/workers'); 682 state.workersRef = ref; 683 ref.on('value', (e) => { 684 state.dbWorker = e.val() || {}; 685 scheduleRedraw(); 686 }); 687} 688 689async function showLogTail(jobId) { 690 if (state.termJobId === jobId) return; // Already on it. 691 const TAIL = 20; 692 state.termClear = true; 693 state.termLines = [ 694 `Fetching last ${TAIL} lines for ${jobId}.`, 695 `Click on the CI icon to see the full log.` 696 ]; 697 state.termJobId = jobId; 698 scheduleRedraw(); 699 const ref = firebase.database().ref(`/ci/logs/${jobId}`); 700 const lines = (await ref.orderByKey().limitToLast(TAIL).once('value')).val(); 701 if (state.termJobId !== jobId || !lines) return; 702 const lastKey = appendLogLinesAndRedraw(lines); 703 startRealTimeLogs(jobId, lastKey); 704} 705 706async function showFullLog(jobId) { 707 state.termClear = true; 708 state.termLines = [`Fetching full for ${jobId} ...`]; 709 state.termJobId = jobId; 710 scheduleRedraw(); 711 712 // Suspend any other real-time logging in progress. 713 stopRealTimeLogs(); 714 715 // Starts a chain of async tasks that fetch the current log lines in batches. 716 state.termJobId = jobId; 717 const ref = firebase.database().ref(`/ci/logs/${jobId}`).orderByKey(); 718 let lastKey = ''; 719 const BATCH = 1000; 720 for (; ;) { 721 const batchRef = ref.startAt(`${lastKey}!`).limitToFirst(BATCH); 722 const logs = (await batchRef.once('value')).val(); 723 if (!logs) 724 break; 725 lastKey = appendLogLinesAndRedraw(logs); 726 } 727 728 startRealTimeLogs(jobId, lastKey) 729} 730 731function startRealTimeLogs(jobId, lastLineKey) { 732 stopRealTimeLogs(); 733 console.log('Starting real-time logs for ', jobId); 734 state.termJobId = jobId; 735 let ref = firebase.database().ref(`/ci/logs/${jobId}`); 736 ref = ref.orderByKey().startAt(`${lastLineKey}!`); 737 state.realTimeLogRef = ref; 738 state.realTimeLogRef.on('child_added', res => { 739 const line = res.val(); 740 if (state.termJobId !== jobId || !line) return; 741 const lines = {}; 742 lines[res.key] = line; 743 appendLogLinesAndRedraw(lines); 744 }); 745} 746 747function stopRealTimeLogs() { 748 if (state.realTimeLogRef !== undefined) { 749 state.realTimeLogRef.off(); 750 state.realTimeLogRef = undefined; 751 } 752} 753 754function appendLogLinesAndRedraw(lines) { 755 const keys = Object.keys(lines).sort(); 756 for (var key of keys) { 757 const date = new Date(null); 758 date.setSeconds(parseInt(key.substr(0, 6), 16) / 1000); 759 const timeString = date.toISOString().substr(11, 8); 760 const isErr = lines[key].indexOf('FAILED:') >= 0; 761 let line = `[${timeString}] ${lines[key]}`; 762 if (isErr) line = `\u001b[33m${line}\u001b[0m`; 763 state.termLines.push(line); 764 } 765 scheduleRedraw(); 766 return keys[keys.length - 1]; 767} 768 769async function fetchRecentJobsStatus() { 770 const db = firebase.database(); 771 if (state.jobsQueuedRef === undefined) { 772 state.jobsQueuedRef = db.ref(`/ci/jobs_queued`).on('value', e => { 773 state.jobsQueued = Object.keys(e.val() || {}).sort().reverse(); 774 for (const jobId of state.jobsQueued) 775 fetchCIStatusForJob(jobId); 776 scheduleRedraw(); 777 }); 778 } 779 780 if (state.jobsRunningRef === undefined) { 781 state.jobsRunningRef = db.ref(`/ci/jobs_running`).on('value', e => { 782 state.jobsRunning = Object.keys(e.val() || {}).sort().reverse(); 783 for (const jobId of state.jobsRunning) 784 fetchCIStatusForJob(jobId); 785 scheduleRedraw(); 786 }); 787 } 788 789 if (state.jobsRecentRef === undefined) { 790 state.jobsRecentRef = db.ref(`/ci/jobs`).orderByKey().limitToLast(100); 791 state.jobsRecentRef.on('value', e => { 792 state.jobsRecent = Object.keys(e.val() || {}).sort().reverse(); 793 for (const jobId of state.jobsRecent) 794 fetchCIStatusForJob(jobId); 795 scheduleRedraw(); 796 }); 797 } 798} 799 800 801function scheduleRedraw() { 802 if (state.redrawPending) return; 803 state.redrawPending = true; 804 window.requestAnimationFrame(() => { 805 state.redrawPending = false; 806 m.redraw(); 807 }); 808} 809 810main(); 811