xref: /aosp_15_r20/external/perfetto/infra/ci/frontend/static/script.js (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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