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