| /*! |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| /* global calendarData, statesColors, document, window, $, d3, moment */ |
| import { getMetaValue } from './utils'; |
| |
| const gridUrl = getMetaValue('grid_url'); |
| |
| function getGridViewURL(d) { |
| return `${gridUrl}?base_date=${encodeURIComponent(d.toISOString())}`; |
| } |
| |
| // date helpers |
| function formatDay(d) { |
| return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d]; |
| } |
| |
| function toMoment(y, m, d) { |
| return moment.utc([y, m, d]); |
| } |
| |
| function weekOfMonth(y, m, d) { |
| const monthOffset = toMoment(y, m, 1).day(); |
| const dayOfMonth = toMoment(y, m, d).date(); |
| return Math.floor((dayOfMonth + monthOffset - 1) / 7); |
| } |
| |
| function weekOfYear(y, m) { |
| const yearOffset = toMoment(y, 0, 1).day(); |
| const dayOfYear = toMoment(y, m, 1).dayOfYear(); |
| return Math.floor((dayOfYear + yearOffset - 1) / 7); |
| } |
| |
| function daysInMonth(y, m) { |
| const lastDay = toMoment(y, m, 1).add(1, 'month').subtract(1, 'day'); |
| return lastDay.date(); |
| } |
| |
| function weeksInMonth(y, m) { |
| const firstDay = toMoment(y, m, 1); |
| const monthOffset = firstDay.day(); |
| return Math.floor((daysInMonth(y, m) + monthOffset) / 7) + 1; |
| } |
| |
| const dateFormat = 'YYYY-MM-DD'; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| $('span.status_square').tooltip({ html: true }); |
| |
| // JSON.parse is faster for large payloads than an object literal |
| const rootData = JSON.parse(calendarData); |
| |
| const dayTip = d3.tip() |
| .attr('class', 'tooltip d3-tip') |
| .html((toolTipHtml) => toolTipHtml); |
| |
| // draw the calendar |
| function draw() { |
| // display constants |
| const leftRightMargin = 32; |
| const titleHeight = 24; |
| const yearLabelWidth = 34; |
| const dayLabelWidth = 14; |
| const dayLabelPadding = 4; |
| const yearPadding = 20; |
| const cellSize = 16; |
| const yearHeight = cellSize * 7 + 2; |
| const maxWeeksInYear = 53; |
| const legendHeight = 30; |
| const legendSwatchesPadding = 4; |
| const legendSwtchesTextWidth = 44; |
| |
| // group dag run stats by year -> month -> day -> state |
| let dagStates = d3 |
| .nest() |
| .key((dr) => moment.utc(dr.date, dateFormat).year()) |
| .key((dr) => moment.utc(dr.date, dateFormat).month()) |
| .key((dr) => moment.utc(dr.date, dateFormat).date()) |
| .key((dr) => dr.state) |
| .map(rootData.dag_states); |
| |
| // Make sure we have one year displayed for each year between the start and end dates. |
| // This also ensures we do not have show an empty calendar view when no dag runs exist. |
| const startYear = moment.utc(rootData.start_date, dateFormat).year(); |
| const endYear = moment.utc(rootData.end_date, dateFormat).year(); |
| for (let y = startYear; y <= endYear; y += 1) { |
| dagStates[y] = dagStates[y] || {}; |
| } |
| |
| dagStates = d3 |
| .entries(dagStates) |
| .map((keyVal) => ({ |
| year: keyVal.key, |
| dagStates: keyVal.value, |
| })) |
| .sort((data) => data.year); |
| |
| // root SVG element |
| const fullWidth = ( |
| leftRightMargin * 2 + yearLabelWidth + dayLabelWidth |
| + maxWeeksInYear * cellSize |
| ); |
| const yearsHeight = (yearHeight + yearPadding) * dagStates.length + yearPadding; |
| const fullHeight = titleHeight + legendHeight + yearsHeight; |
| |
| const svg = d3 |
| .select('#calendar-svg') |
| .attr('width', fullWidth) |
| .attr('height', fullHeight) |
| .call(dayTip); |
| |
| // Add the legend |
| const legend = svg |
| .append('g') |
| .attr('transform', `translate(0, ${titleHeight + legendHeight / 2})`); |
| |
| let legendXOffset = fullWidth - leftRightMargin; |
| |
| function drawLegend(rightState, leftState, numSwatches = 1, swatchesWidth = cellSize) { |
| const startColor = statesColors[leftState || rightState]; |
| const endColor = statesColors[rightState]; |
| |
| legendXOffset -= legendSwtchesTextWidth; |
| legend |
| .append('text') |
| .attr('x', legendXOffset) |
| .attr('y', cellSize / 2) |
| .attr('text-anchor', 'start') |
| .attr('class', 'status-label') |
| .attr('alignment-baseline', 'middle') |
| .text(rightState); |
| legendXOffset -= legendSwatchesPadding; |
| |
| legendXOffset -= swatchesWidth; |
| legend |
| .append('g') |
| .attr('transform', `translate(${legendXOffset}, 0)`) |
| .selectAll('g') |
| .data(d3.range(numSwatches)) |
| .enter() |
| .append('rect') |
| .attr('x', (v) => v * (swatchesWidth / numSwatches)) |
| .attr('width', swatchesWidth / numSwatches) |
| .attr('height', cellSize) |
| .attr('class', 'day') |
| .attr('fill', (v) => (startColor.startsWith('url') ? startColor : d3.interpolateHsl(startColor, endColor)(v / numSwatches))); |
| legendXOffset -= legendSwatchesPadding; |
| |
| if (leftState !== undefined) { |
| legend |
| .append('text') |
| .attr('x', legendXOffset) |
| .attr('y', cellSize / 2) |
| .attr('text-anchor', 'end') |
| .attr('class', 'status-label') |
| .attr('alignment-baseline', 'middle') |
| .text(leftState); |
| legendXOffset -= legendSwtchesTextWidth; |
| } |
| } |
| |
| drawLegend('no_status'); |
| drawLegend('planned'); |
| drawLegend('running'); |
| drawLegend('failed', 'success', 10, 100); |
| |
| // Add the years groups, each holding one year of data. |
| const years = svg |
| .append('g') |
| .attr('transform', `translate(${leftRightMargin}, ${titleHeight + legendHeight})`); |
| |
| const year = years |
| .selectAll('g') |
| .data(dagStates) |
| .enter() |
| .append('g') |
| .attr('transform', (d, i) => `translate(0, ${yearPadding + (yearHeight + yearPadding) * i})`); |
| |
| year |
| .append('text') |
| .attr('x', -yearHeight * 0.5) |
| .attr('transform', 'rotate(270)') |
| .attr('text-anchor', 'middle') |
| .attr('class', 'year-label') |
| .text((d) => d.year); |
| |
| // write day names |
| year |
| .append('g') |
| .attr('transform', `translate(${yearLabelWidth}, ${dayLabelPadding})`) |
| .attr('text-anchor', 'end') |
| .selectAll('g') |
| .data(d3.range(7)) |
| .enter() |
| .append('text') |
| .attr('y', (i) => (i + 0.5) * cellSize) |
| .attr('class', 'day-label') |
| .text(formatDay); |
| |
| // create months groups to old the individual day cells & month outline for each month. |
| const months = year |
| .append('g') |
| .attr('transform', `translate(${yearLabelWidth + dayLabelWidth}, 0)`); |
| |
| const month = months |
| .append('g') |
| .selectAll('g') |
| .data((data) => d3 |
| .range(12) |
| .map((i) => ({ |
| year: data.year, |
| month: i, |
| dagStates: data.dagStates[i] || {}, |
| }))) |
| .enter() |
| .append('g') |
| .attr('transform', (data) => `translate(${weekOfYear(data.year, data.month) * cellSize}, 0)`); |
| |
| const tipHtml = (data) => { |
| const stateCounts = d3.entries(data.dagStates).map((kv) => `${kv.value[0].count} ${kv.key}`); |
| const date = toMoment(data.year, data.month, data.day); |
| const daySr = formatDay(date.day()); |
| const dateStr = date.format(dateFormat); |
| return `<strong>${daySr} ${dateStr}</strong><br>${stateCounts.join('<br>')}`; |
| }; |
| |
| // Create the day cells |
| month |
| .selectAll('g') |
| .data((data) => d3 |
| .range(daysInMonth(data.year, data.month)) |
| .map((i) => { |
| const day = i + 1; |
| const dagRunsByState = data.dagStates[day] || {}; |
| return { |
| year: data.year, |
| month: data.month, |
| day, |
| dagStates: dagRunsByState, |
| }; |
| })) |
| .enter() |
| .append('rect') |
| .attr('x', (data) => weekOfMonth(data.year, data.month, data.day) * cellSize) |
| .attr('y', (data) => toMoment(data.year, data.month, data.day).day() * cellSize) |
| .attr('width', cellSize) |
| .attr('height', cellSize) |
| .attr('class', 'day') |
| .attr('fill', (data) => { |
| const getCount = (state) => (data.dagStates[state] || [{ count: 0 }])[0].count; |
| const runningCount = getCount('running'); |
| if (runningCount > 0) return statesColors.running; |
| |
| const successCount = getCount('success'); |
| const failedCount = getCount('failed'); |
| if (successCount + failedCount === 0) { |
| const plannedCount = getCount('planned'); |
| if (plannedCount > 0) return statesColors.planned; |
| return statesColors.no_status; |
| } |
| |
| let ratioFailures; |
| if (failedCount === 0) ratioFailures = 0; |
| else { |
| // We use a minimum color interpolation floor, so that days with low failures ratios |
| // don't appear almost as green as days with not failure at all. |
| const floor = 0.5; |
| ratioFailures = floor + (failedCount / (failedCount + successCount)) * (1 - floor); |
| } |
| return d3.interpolateHsl(statesColors.success, statesColors.failed)(ratioFailures); |
| }) |
| .on('click', (data) => { |
| window.location.href = getGridViewURL( |
| // add 1 day and subtract 1 ms to not show any run from the next day. |
| toMoment(data.year, data.month, data.day).add(1, 'day').subtract(1, 'ms'), |
| ); |
| }) |
| .on('mouseover', function showTip(data) { |
| const tt = tipHtml(data); |
| dayTip.direction('n'); |
| dayTip.show(tt, this); |
| }) |
| .on('mouseout', function hideTip(data) { |
| dayTip.hide(data, this); |
| }); |
| |
| // add outline (path) around month |
| month |
| .selectAll('g') |
| .data((data) => [data]) |
| .enter() |
| .append('path') |
| .attr('class', 'month') |
| .style('fill', 'none') |
| .attr('d', (data) => { |
| const firstDayOffset = toMoment(data.year, data.month, 1).day(); |
| const lastDayOffset = toMoment(data.year, data.month, 1).add(1, 'month').day(); |
| const weeks = weeksInMonth(data.year, data.month); |
| return d3.svg.line()([ |
| [0, firstDayOffset * cellSize], |
| [cellSize, firstDayOffset * cellSize], |
| [cellSize, 0], |
| [weeks * cellSize, 0], |
| [weeks * cellSize, lastDayOffset * cellSize], |
| [(weeks - 1) * cellSize, lastDayOffset * cellSize], |
| [(weeks - 1) * cellSize, 7 * cellSize], |
| [0, 7 * cellSize], |
| [0, firstDayOffset * cellSize], |
| ]); |
| }); |
| } |
| |
| function update() { |
| $('#loading').remove(); |
| draw(); |
| } |
| |
| update(); |
| }); |