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