diff --git a/README.md b/README.md index 02a1c09a..04d5c587 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ For all available CLI options see the [Basic CLI docs](https://marketsquare.gith - 📄 **Dashboard Pages** - Overview, Dashboard, and Compare pages for multi-level insights. - 🎛️ **Customizable Layouts** - Drag-and-drop sections with adjustable size and order. - 📊 **Graph Customization** - Toggle legends, axis titles, labels, and control animations. -- 🔎 **Global Filters** - Filter runs by name, tags, date, metadata, or quantity (applied per project). Every filter option shows how many runs it still matches, and options that match none are greyed out. +- 🔎 **Global Filters** - Filter runs by name, tags, date, metadata, or quantity (applied per project). Drag across the runs-over-time histogram to zoom into a period. Every filter option shows how many runs it still matches, and options that match none are greyed out. - ⚖️ **Comparison Mode** - Compare up to 4 runs side by side with visual statistics. - 🔗 **Automatic Log Linking** - Open Robot Framework logs directly from the dashboard. - 🔁 **Rerun History** - Merged `--rerunfailed` outputs keep the status of every attempt per test. diff --git a/docs/filtering.md b/docs/filtering.md index 081c4338..7764d8e4 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -74,17 +74,25 @@ Example: with two custom filters where `filter_1=A` only ever occurs together wi - Typing in the search box inside the dropdown selects every matching version and unselects the rest (e.g. `1.` selects all `1.x` versions in one go). - Click the **X** in the search box to clear it while keeping the current selection. Deleting characters one by one instead re-runs the matching on every keystroke. -#### 4. From Date / From Time +#### 4. Runs over Time (date histogram) -- Sets the earliest point in time a run must have started at to be included. -- Runs that started before this date and time are excluded. -- Defaults to the date and time of the oldest run in the data (with a small margin to account for seconds and daylight saving time). +A bar chart of how many runs started in each time bucket, sitting directly above the date range. It replaces typing dates with dragging: the bars show where the runs actually are, so you can pick the period straight off the chart. -#### 5. To Date / To Time +- **Bars** show the number of runs per bucket, stacked by run status: red for runs with failures, yellow for runs that only skipped, green for the rest. Vertical separators mark every bucket, so gaps in the data stay readable. +- **Drag across the chart** to select a range. The From/To fields below are filled in and the chart **zooms into the selection**, re-bucketing finer, so dragging again drills further down (a month at a bar per day, then a week at a bar per 6 hours, then a day at a bar per hour). +- **Click a single bar** to select just that bucket. +- **Reset Range** returns to the full span of the data. Widening the From/To fields by hand does the same. +- The bucket size is chosen from the visible range and is printed next to the chart as `1 bar = 1 day`. Hovering a bar shows its exact time range and, per run in it, how many tests passed, failed and were skipped — the same shape as the hover on the run graphs. +- The bars follow every other filter (runs, run tags, versions, metadata, custom filters, suite path) but not the date range itself — the date range is the part of the timeline you are looking at, which is why widening it brings the runs outside it straight back. The *Amount per project* filter is not applied to the bars either. -- Sets the latest point in time a run must have started at to be included. -- Runs that started after this date and time are excluded. -- Defaults to the date and time of the most recent run in the data (with a small margin). +#### 5. From / To Date and Time + +The four fields below the chart are the range itself, and they stay editable by hand. + +- **From Date / From Time** set the earliest point in time a run must have started at to be included; runs that started before it are excluded. +- **To Date / To Time** set the latest point in time; runs that started after it are excluded. +- They default to the oldest and most recent run in the data (with a small margin to account for seconds and daylight saving time), which is also what **Reset Range** restores. +- Typing in them moves the histogram above, exactly like dragging moves them. #### 6. Metadata diff --git a/robotframework_dashboard/css/components/02-filters.css b/robotframework_dashboard/css/components/02-filters.css index 9cbf2464..4754b125 100644 --- a/robotframework_dashboard/css/components/02-filters.css +++ b/robotframework_dashboard/css/components/02-filters.css @@ -155,3 +155,93 @@ li.filter-option-unavailable > .filter-option-count { .filter-profile-delete:hover { opacity: 1; } + +.date-histogram-wrapper { + position: relative; + width: 100%; + height: 160px; + /* the drag selection is a pointer gesture, so the browser may not claim it for panning */ + touch-action: none; + cursor: crosshair; +} + +.date-histogram-selection { + position: absolute; + top: 0; + bottom: 0; + background-color: rgba(54, 162, 235, 0.2); + border-left: 1px solid rgba(54, 162, 235, 0.8); + border-right: 1px solid rgba(54, 162, 235, 0.8); + pointer-events: none; +} + +.date-histogram-bucket, +.date-histogram-empty { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.date-range-row { + gap: 0.75rem 1.25rem; +} + +.date-range-field { + flex: 0 0 auto; +} + +.date-range-label { + margin-right: 0.5rem; + min-width: 2.5rem; +} + +.date-range-input { + width: 150px; +} + +/* shaped after the Chart.js tooltip the other graphs use, but an element of its own: the details + per run are taller than the chart and carry a colour per row, neither of which the tooltip on + the canvas can do */ +.date-histogram-tooltip { + position: absolute; + top: 8px; + z-index: 3; + max-width: 320px; + padding: 6px 8px; + border-radius: 6px; + background-color: rgba(0, 0, 0, 0.8); + color: #fff; + font-size: 0.75rem; + line-height: 1.35; + pointer-events: none; +} + +.date-histogram-tooltip-title { + font-weight: 700; + margin-bottom: 0.25rem; +} + +.date-histogram-tooltip-run + .date-histogram-tooltip-run { + margin-top: 0.35rem; +} + +.date-histogram-tooltip-run-name { + opacity: 0.85; +} + +.date-histogram-tooltip-row { + display: flex; + align-items: center; +} + +.date-histogram-tooltip-dot { + width: 9px; + height: 9px; + margin-right: 0.4rem; + border-radius: 2px; + flex-shrink: 0; +} + +.date-histogram-tooltip-empty, +.date-histogram-tooltip-more { + opacity: 0.85; +} diff --git a/robotframework_dashboard/js/eventlisteners/filter_modal.js b/robotframework_dashboard/js/eventlisteners/filter_modal.js index d6dc748f..084ea9a7 100644 --- a/robotframework_dashboard/js/eventlisteners/filter_modal.js +++ b/robotframework_dashboard/js/eventlisteners/filter_modal.js @@ -32,6 +32,11 @@ import { schedule_filter_option_availability_refresh, set_filter_modal_open, } from "../filter/availability.js"; +import { + render_date_histogram, + schedule_date_histogram_refresh, + setup_date_histogram, +} from "../filter/date_histogram.js"; import { confirm_action } from "./confirm_modal.js"; import { setup_merge_profiles_modal } from "./merge_profiles.js"; @@ -65,10 +70,16 @@ function setup_filter_modal() { document.getElementById("filtersModal").addEventListener("show.bs.modal", function () { set_filter_modal_open(true); }); + // Chart.js cannot size a canvas inside a hidden element, so the histogram waits for the modal + // to be on screen instead of drawing on show.bs.modal + document.getElementById("filtersModal").addEventListener("shown.bs.modal", function () { + render_date_histogram(); + }); // eventlistener to reset the filters document.getElementById("resetFilters").addEventListener("click", function () { clear_all_filters(); clear_active_profile(); + schedule_date_histogram_refresh(); add_alert("Filters have been set to default values!", "success") update_profile_select_display(); }); @@ -147,6 +158,7 @@ function setup_filter_modal() { setup_project_versions_in_select_filter_buttons(); setup_suite_path_navigator("All"); setup_custom_filters_in_select_filter_buttons(); + setup_date_histogram(); // snapshot the default/initial filter state so profile checkboxes can reflect changes capture_default_filters(); // filter profiles setup @@ -207,6 +219,7 @@ function setup_filter_modal() { const profile = profiles[name]; if (profile) { apply_filter_profile(profile, name); + schedule_date_histogram_refresh(); add_alert(`Filter profile "${name}" applied`, "success"); update_profile_select_display(); populate_filter_profile_select(); diff --git a/robotframework_dashboard/js/filter/availability.js b/robotframework_dashboard/js/filter/availability.js index def9d6e7..69e87d35 100644 --- a/robotframework_dashboard/js/filter/availability.js +++ b/robotframework_dashboard/js/filter/availability.js @@ -79,8 +79,9 @@ function apply_filters_except(runList, selections, facet, dimName = null, suiteL if (facet === "customFilters" && dim === dimName) { continue; } result = apply_custom_filter_dimension(result, dim, dimSelection.values, dimSelection.mode); } - // the date range and the suite path have no option lists to count, so they always apply - if (selections.dateRange) { + // the suite path has no option list to count, so it always applies. The date range applies + // too, except for the date histogram, which draws the runs the window is chosen from. + if (facet !== "dates" && selections.dateRange) { result = apply_date_filter(result, selections.dateRange.from, selections.dateRange.to); } if (selections.suitePath && selections.suitePath !== "All") { @@ -158,6 +159,14 @@ function compute_filter_option_availability(runList, selections, suiteList = nul return availability; } +// the runs the date histogram draws: every run level filter except the date range itself, +// so the bars still cover the runs outside the window and widening it brings them back. The +// amount filter is left out for the same reason it is left out of the option counts. +function get_runs_for_date_histogram() { + const selections = normalize_filter_selections(capture_current_filters()); + return apply_filters_except(get_filter_base_runs(), selections, "dates"); +} + // add or update the "(X)" count of one filter option row. The count is a sibling of the // label, not a child: the row is the flex container, so only a direct child of the row can // be pushed into its own right-aligned column. @@ -252,6 +261,7 @@ function schedule_filter_option_availability_refresh() { export { compute_filter_option_availability, + get_runs_for_date_histogram, normalize_filter_selections, refresh_filter_option_availability, schedule_filter_option_availability_refresh, diff --git a/robotframework_dashboard/js/filter/date_histogram.js b/robotframework_dashboard/js/filter/date_histogram.js new file mode 100644 index 00000000..8b162b7f --- /dev/null +++ b/robotframework_dashboard/js/filter/date_histogram.js @@ -0,0 +1,488 @@ +import { settings } from '../variables/settings.js'; +import { failedBackgroundBorderColor, failedConfig, passedBackgroundBorderColor, passedConfig, skippedBackgroundBorderColor, skippedConfig } from '../variables/chartconfig.js'; +import { build_date_range, get_run_start_date } from './pipeline.js'; +import { get_runs_for_date_histogram } from './availability.js'; +import { setup_lowest_highest_dates } from './modal_options.js'; + +// Drag to zoom for the date filter: the bars are the runs per time bucket inside the current +// window, and dragging across them writes the four date/time inputs. Those inputs stay the only +// place the range lives, so the window *is* the selection: zooming in is a drag, zooming out is +// widening them (or the Reset button), and filter profiles need to know nothing about the chart. + +const MINUTE = 60 * 1000; +// the window is drawn with the smallest bucket that keeps the bar count at or below this +const MAX_HISTOGRAM_BUCKETS = 60; +// at most this many bars carry a label under them +const MAX_HISTOGRAM_LABELS = 12; +// at most this many runs of a bucket are listed in its tooltip +const MAX_TOOLTIP_RUNS = 6; +// the three status rows of a run, in the order the run statistics graph lists them +const TOOLTIP_STATUS_ROWS = [ + { key: "failed", label: "Failed", color: failedBackgroundBorderColor }, + { key: "skipped", label: "Skipped", color: skippedBackgroundBorderColor }, + { key: "passed", label: "Passed", color: passedBackgroundBorderColor }, +]; +// the default gridline colour of the dashboard is a 10% wash, too faint to count buckets along +const HISTOGRAM_GRID_COLOR = "rgba(128, 128, 128, 0.35)"; +// a window far wider than its largest bucket would still build a bar per month, so the loop that +// walks the buckets is capped as well +const BUCKET_LIMIT = 600; +const BUCKET_UNITS = [ + { key: "minute", label: "1 minute", ms: MINUTE }, + { key: "5minutes", label: "5 minutes", ms: 5 * MINUTE }, + { key: "15minutes", label: "15 minutes", ms: 15 * MINUTE }, + { key: "hour", label: "1 hour", ms: 60 * MINUTE }, + { key: "6hours", label: "6 hours", ms: 6 * 60 * MINUTE }, + { key: "day", label: "1 day", ms: 24 * 60 * MINUTE }, + { key: "week", label: "1 week", ms: 7 * 24 * 60 * MINUTE }, + { key: "month", label: "1 month", ms: 30 * 24 * 60 * MINUTE }, +]; +const DAY_MS = 24 * 60 * MINUTE; +// a drag shorter than this is a click on a single bar instead of a range +const CLICK_TOLERANCE_PIXELS = 4; + +// the buckets the bars were built from, needed to turn a pixel back into a date range +let dateHistogramBuckets = []; + +const pad = (value) => String(value).padStart(2, "0"); + +function pick_bucket_unit(spanMs) { + return BUCKET_UNITS.find(unit => spanMs / unit.ms <= MAX_HISTOGRAM_BUCKETS) ?? BUCKET_UNITS[BUCKET_UNITS.length - 1]; +} + +// buckets start on their own boundary (a whole hour, midnight, a Monday, the first of the month) +// so the bars line up with how a reader thinks about the unit instead of with the window edge +function floor_to_bucket(date, unit) { + const floored = new Date(date.getTime()); + floored.setSeconds(0, 0); + if (unit.key === "minute") { return floored; } + if (unit.ms < 60 * MINUTE) { + const step = unit.ms / MINUTE; + floored.setMinutes(Math.floor(floored.getMinutes() / step) * step); + return floored; + } + floored.setMinutes(0); + if (unit.key === "hour") { return floored; } + if (unit.key === "6hours") { + floored.setHours(Math.floor(floored.getHours() / 6) * 6); + return floored; + } + floored.setHours(0); + if (unit.key === "day") { return floored; } + if (unit.key === "week") { + floored.setDate(floored.getDate() - ((floored.getDay() + 6) % 7)); // back to Monday + return floored; + } + floored.setDate(1); + return floored; +} + +// days, weeks and months advance by calendar field: adding their length in milliseconds would +// shift every bucket after a DST switch by an hour +function advance_bucket(date, unit) { + const next = new Date(date.getTime()); + if (unit.key === "day") { next.setDate(next.getDate() + 1); return next; } + if (unit.key === "week") { next.setDate(next.getDate() + 7); return next; } + if (unit.key === "month") { next.setMonth(next.getMonth() + 1); return next; } + return new Date(next.getTime() + unit.ms); +} + +// the same rule the overview run cards use: any failure makes the run failed, a run without +// passes that skipped something is skipped +function get_run_status(run) { + if (run.failed > 0) { return "failed"; } + if (run.skipped > 0 && run.passed === 0) { return "skipped"; } + return "passed"; +} + +function find_bucket_index(buckets, time) { + let low = 0; + let high = buckets.length - 1; + while (low <= high) { + const middle = (low + high) >> 1; + if (time < buckets[middle].start.getTime()) { high = middle - 1; } + else if (time >= buckets[middle].end.getTime()) { low = middle + 1; } + else { return middle; } + } + return -1; +} + +function build_histogram_buckets(runList, from, to, unit) { + const buckets = []; + let start = floor_to_bucket(from, unit); + while (start <= to && buckets.length < BUCKET_LIMIT) { + const end = advance_bucket(start, unit); + buckets.push({ start: start, end: end, passed: 0, failed: 0, skipped: 0, runs: [] }); + start = end; + } + let total = 0; + for (const run of runList) { + const runStart = get_run_start_date(run); + if (runStart < from || runStart > to) { continue; } + const index = find_bucket_index(buckets, runStart.getTime()); + if (index === -1) { continue; } + buckets[index][get_run_status(run)] += 1; + buckets[index].runs.push({ start: runStart, name: run.name, passed: run.passed, failed: run.failed, skipped: run.skipped }); + total += 1; + } + for (const bucket of buckets) { + bucket.runs.sort((a, b) => a.start - b.start); + } + return { buckets: buckets, unit: unit, total: total }; +} + +// no year on the bars: the axis has to stay readable at a label every few buckets, and the +// full dates are right below the chart in the from/to inputs +function format_bucket_label(date, unit) { + const day = `${pad(date.getDate())}.${pad(date.getMonth() + 1)}`; + if (unit.key === "month") { return `${pad(date.getMonth() + 1)}.${date.getFullYear()}`; } + if (unit.ms < DAY_MS) { return `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } + return day; +} + +function format_bucket_datetime(date) { + return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +// the end of a bucket is the start of the next one, so the last minute it contains is a minute +// before that. A bucket that begins and ends on one day names that day once. +function format_bucket_range(bucket) { + const last = new Date(bucket.end.getTime() - MINUTE); + const end = bucket.start.toDateString() === last.toDateString() + ? `${pad(last.getHours())}:${pad(last.getMinutes())}` + : format_bucket_datetime(last); + return `${format_bucket_datetime(bucket.start)} - ${end}`; +} + +function format_date_input_value(date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function format_time_input_value(date) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +// the window the bars cover: the selected range, or the full span of the runs while the range +// is incomplete or inverted (the pipeline is the one that reports an inverted range) +function get_histogram_window(runList) { + const range = build_date_range( + document.getElementById("fromDate").value, + document.getElementById("fromTime").value, + document.getElementById("toDate").value, + document.getElementById("toTime").value, + ); + if (range) { return range; } + if (runList.length === 0) { return null; } + const times = runList.map(run => get_run_start_date(run).getTime()); + return { from: new Date(Math.min(...times)), to: new Date(Math.max(...times)) }; +} + +// Chart.js only draws a gridline under a tick that carries a label, which leaves a row of empty +// buckets as one wide gap with nothing to read a time off. The separators are therefore drawn +// per bucket edge here: the categories are evenly spaced, so the edges are plain arithmetic. +const dateHistogramSeparatorsPlugin = { + id: "dateHistogramSeparators", + beforeDatasetsDraw(chart) { + const { ctx, chartArea } = chart; + const buckets = chart.data.labels.length; + if (!buckets) { return; } + const bucketWidth = (chartArea.right - chartArea.left) / buckets; + ctx.save(); + ctx.strokeStyle = HISTOGRAM_GRID_COLOR; + ctx.lineWidth = 1; + for (let edge = 0; edge <= buckets; edge++) { + // the half pixel keeps the line on one device pixel instead of blurring over two + const x = Math.round(chartArea.left + edge * bucketWidth) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, chartArea.top); + ctx.lineTo(x, chartArea.bottom); + ctx.stroke(); + } + ctx.restore(); + }, +}; + +// what a bucket holds: a block per run with the three status rows the run statistics graph +// shows. Long buckets are cut off, the tooltip would otherwise outgrow the modal. +function build_tooltip_content(histogram, dataIndex) { + const bucket = histogram.buckets[dataIndex]; + if (!bucket) { return null; } + const runs = bucket.runs.slice(0, MAX_TOOLTIP_RUNS).map(run => ({ + label: `${format_run_time(run.start, histogram.unit)} ${run.name}`, + rows: TOOLTIP_STATUS_ROWS.map(row => ({ label: row.label, color: row.color, value: run[row.key] })), + })); + return { + title: format_bucket_range(bucket), + runs: runs, + hiddenRuns: Math.max(bucket.runs.length - MAX_TOOLTIP_RUNS, 0), + }; +} + +// the title of the tooltip already carries the date of the bucket, so a run only repeats it +// when one bar covers more than a single day +function format_run_time(date, unit) { + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`; + return unit.ms > DAY_MS ? `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${time}` : time; +} + +function add_tooltip_line(parent, className, text) { + const line = document.createElement("div"); + line.className = className; + line.textContent = text; + parent.appendChild(line); + return line; +} + +// Chart.js paints its tooltip on the canvas, which clips whatever is taller than the chart and +// gives every line the same colour. A block per run needs neither, so this chart renders its +// tooltip as an element beside the canvas, in the shape the Chart.js one has. +function render_tooltip_element(context) { + const element = document.getElementById("dateHistogramTooltip"); + if (!element) { return; } + const { chart, tooltip } = context; + if (tooltip.opacity === 0) { + element.hidden = true; + return; + } + const content = build_tooltip_content(chart.$dateHistogram, tooltip.dataPoints[0]?.dataIndex); + if (!content) { + element.hidden = true; + return; + } + element.replaceChildren(); + add_tooltip_line(element, "date-histogram-tooltip-title", content.title); + if (content.runs.length === 0) { + add_tooltip_line(element, "date-histogram-tooltip-empty", "No runs"); + } + for (const run of content.runs) { + const block = document.createElement("div"); + block.className = "date-histogram-tooltip-run"; + add_tooltip_line(block, "date-histogram-tooltip-run-name", run.label); + for (const row of run.rows) { + const line = add_tooltip_line(block, "date-histogram-tooltip-row", `${row.label}: ${row.value}`); + const dot = document.createElement("span"); + dot.className = "date-histogram-tooltip-dot"; + dot.style.backgroundColor = row.color; + line.prepend(dot); + } + element.appendChild(block); + } + if (content.hiddenRuns > 0) { + add_tooltip_line(element, "date-histogram-tooltip-more", + `+ ${content.hiddenRuns} more ${content.hiddenRuns === 1 ? "run" : "runs"}`); + } + element.hidden = false; + // the tooltip follows the bar it belongs to, but may not leave the chart on either side + const offset = 12; + const maxLeft = chart.width - element.offsetWidth - offset; + const left = tooltip.caretX + offset > maxLeft ? tooltip.caretX - element.offsetWidth - offset : tooltip.caretX + offset; + element.style.left = `${Math.max(Math.min(left, maxLeft), offset)}px`; +} + +function build_date_histogram_config(histogram) { + // a separator sits at every bucket edge, but a label at every bucket would be unreadable, + // so only every nth tick keeps its text + const labelStep = Math.ceil(histogram.buckets.length / MAX_HISTOGRAM_LABELS); + const dataset = (label, key, config) => ({ + label: label, + data: histogram.buckets.map(bucket => bucket[key]), + ...config, + }); + return { + type: "bar", + plugins: [dateHistogramSeparatorsPlugin], + data: { + labels: histogram.buckets.map(bucket => format_bucket_label(bucket.start, histogram.unit)), + datasets: [ + dataset("Failed", "failed", failedConfig), + dataset("Skipped", "skipped", skippedConfig), + dataset("Passed", "passed", passedConfig), + ], + }, + options: { + normalized: true, + responsive: true, + maintainAspectRatio: false, + // the whole column answers the pointer: a bar of one run is a few pixels high, and an + // empty bucket has nothing to hover at all + interaction: { mode: "index", intersect: false }, + // the chart is rebuilt on every drag, where a growing bar would fight the next drag + animation: false, + plugins: { + legend: { display: settings.show.legends }, + datalabels: { display: false }, + tooltip: { enabled: false, external: render_tooltip_element }, + }, + scales: { + x: { + stacked: true, + // the separators of the plugin above are the gridlines of this scale + grid: { display: false }, + // every bucket keeps a tick of its own, so the labels below fall on a fixed + // multiple of buckets instead of wherever autoSkip decides to put them + ticks: { + autoSkip: false, + // without this the ticks stop at the default of 11, below the bucket count + // of most windows + maxTicksLimit: histogram.buckets.length, + maxRotation: 0, + callback: function (value, index) { + return index % labelStep === 0 ? this.getLabelForValue(value) : ""; + }, + }, + }, + y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } }, + }, + }, + }; +} + +function show_histogram_message(message) { + const messageElement = document.getElementById("dateHistogramEmpty"); + const wrapper = document.getElementById("dateHistogramWrapper"); + const bucketLabel = document.getElementById("dateHistogramBucket"); + if (window.dateHistogramChart) { + window.dateHistogramChart.destroy(); + window.dateHistogramChart = null; + } + dateHistogramBuckets = []; + if (wrapper) { wrapper.hidden = true; } + if (bucketLabel) { bucketLabel.textContent = ""; } + if (messageElement) { + messageElement.textContent = message; + messageElement.hidden = false; + } +} + +// (re)draw the bars for the current window. Chart.js cannot size a canvas inside a hidden +// element, and writing into the modal while it fades out competes with the animation, so this +// only runs while the modal is on screen. +function render_date_histogram() { + const wrapper = document.getElementById("dateHistogramWrapper"); + if (!wrapper || !document.getElementById("filtersModal")?.classList.contains("show")) { return; } + const runList = get_runs_for_date_histogram(); + const histogramWindow = get_histogram_window(runList); + if (!histogramWindow) { + show_histogram_message("No runs match the other filters."); + return; + } + const unit = pick_bucket_unit(histogramWindow.to - histogramWindow.from); + const histogram = build_histogram_buckets(runList, histogramWindow.from, histogramWindow.to, unit); + if (histogram.total === 0) { + show_histogram_message(runList.length === 0 + ? "No runs match the other filters." + : "No runs in the selected date range."); + return; + } + document.getElementById("dateHistogramEmpty").hidden = true; + wrapper.hidden = false; + document.getElementById("dateHistogramBucket").textContent = `1 bar = ${unit.label}`; + if (window.dateHistogramChart) { window.dateHistogramChart.destroy(); } + // the chart is not kept under the id of its canvas: an element id is a property of window of + // its own, so window.dateHistogramGraph would be the canvas until the first chart overwrites it + window.dateHistogramChart = new Chart("dateHistogramGraph", build_date_histogram_config(histogram)); + // the external tooltip handler is only handed the chart, so the buckets ride along on it + window.dateHistogramChart.$dateHistogram = histogram; + dateHistogramBuckets = histogram.buckets; +} + +// the modal fires an event per control and the histogram redraws on each of them, so the redraw +// is collapsed into one call per frame +let dateHistogramFrame = null; + +function schedule_date_histogram_refresh() { + if (dateHistogramFrame !== null) { return; } + dateHistogramFrame = requestAnimationFrame(() => { + dateHistogramFrame = null; + render_date_histogram(); + }); +} + +// writing the range back into the inputs, which the filter pipeline reads and which the modal +// listeners watch for the active filter dot, the profile state and the option counts +function set_date_range_inputs(from, to) { + document.getElementById("fromDate").value = format_date_input_value(from); + document.getElementById("fromTime").value = format_time_input_value(from); + document.getElementById("toDate").value = format_date_input_value(to); + document.getElementById("toTime").value = format_time_input_value(to); + document.getElementById("toTime").dispatchEvent(new Event("change", { bubbles: true })); +} + +function clamp_to_chart_area(chart, pixel) { + return Math.min(Math.max(pixel, chart.chartArea.left), chart.chartArea.right); +} + +function get_bucket_index_at_pixel(chart, pixel) { + const index = chart.scales.x.getValueForPixel(clamp_to_chart_area(chart, pixel)); + return Math.min(Math.max(Math.round(index), 0), dateHistogramBuckets.length - 1); +} + +// A selection always covers whole buckets: the bars are what the user aims at, and it keeps the +// zoom predictable. The range ends a minute before the next bucket because the inputs only carry +// minutes, and the date filter is inclusive on both sides. +function select_bucket_range(firstIndex, lastIndex) { + const first = dateHistogramBuckets[Math.min(firstIndex, lastIndex)]; + const last = dateHistogramBuckets[Math.max(firstIndex, lastIndex)]; + if (!first || !last) { return; } + set_date_range_inputs(first.start, new Date(last.end.getTime() - MINUTE)); +} + +function setup_date_histogram() { + const wrapper = document.getElementById("dateHistogramWrapper"); + if (!wrapper) { return; } + const canvas = document.getElementById("dateHistogramGraph"); + const selection = document.getElementById("dateHistogramSelection"); + let dragStartPixel = null; + + const draw_selection = (pixel) => { + const chart = window.dateHistogramChart; + const from = clamp_to_chart_area(chart, Math.min(dragStartPixel, pixel)); + const to = clamp_to_chart_area(chart, Math.max(dragStartPixel, pixel)); + selection.style.left = `${from}px`; + selection.style.width = `${to - from}px`; + selection.hidden = false; + }; + + canvas.addEventListener("pointerdown", (event) => { + if (!window.dateHistogramChart) { return; } + dragStartPixel = event.offsetX; + canvas.setPointerCapture(event.pointerId); + draw_selection(event.offsetX); + }); + canvas.addEventListener("pointermove", (event) => { + if (dragStartPixel === null) { return; } + draw_selection(event.offsetX); + }); + canvas.addEventListener("pointerup", (event) => { + if (dragStartPixel === null) { return; } + const chart = window.dateHistogramChart; + const startIndex = get_bucket_index_at_pixel(chart, dragStartPixel); + const endIndex = get_bucket_index_at_pixel(chart, event.offsetX); + const isClick = Math.abs(event.offsetX - dragStartPixel) < CLICK_TOLERANCE_PIXELS; + dragStartPixel = null; + selection.hidden = true; + select_bucket_range(startIndex, isClick ? startIndex : endIndex); + }); + canvas.addEventListener("pointercancel", () => { + dragStartPixel = null; + selection.hidden = true; + }); + document.getElementById("dateHistogramReset").addEventListener("click", () => { + setup_lowest_highest_dates(); + document.getElementById("toTime").dispatchEvent(new Event("change", { bubbles: true })); + }); + // every filter shapes which runs the bars hold, and the date inputs shape the window. Only + // the committed change is watched: an input event fires per keystroke in the date pickers, + // where a half typed date reads as no range at all and would make the window jump. + document.getElementById("filtersModal").addEventListener("change", schedule_date_histogram_refresh); +} + +export { + build_histogram_buckets, + build_tooltip_content, + get_run_status, + pick_bucket_unit, + render_date_histogram, + schedule_date_histogram_refresh, + setup_date_histogram, +}; diff --git a/robotframework_dashboard/js/filter/pipeline.js b/robotframework_dashboard/js/filter/pipeline.js index ab30e548..ef78ed4c 100644 --- a/robotframework_dashboard/js/filter/pipeline.js +++ b/robotframework_dashboard/js/filter/pipeline.js @@ -343,15 +343,20 @@ function build_date_range(fromDate, fromTime, toDate, toTime) { return { from, to }; } +// The date a run started as the date filter sees it: when timezones are not converted, the +// offset is stripped so the run_start is a plain wall-clock time matching the date picker +// values (which are also wall-clock). Not the parse_run_start() of common.js, which keeps the +// offset - every module ends up in one script, so the names may not clash either. +function get_run_start_date(run) { + let rs = run.run_start.replace(" ", "T"); + if (!settings.show.convertTimezone) { + rs = strip_tz_suffix(rs); + } + return new Date(rs); +} function apply_date_filter(runs, fromDateTime, toDateTime) { return runs.filter(run => { - // When not converting timezones, strip any timezone offset so the run_start is treated - // as a plain wall-clock time matching the date picker values (which are also wall-clock). - let rs = run.run_start.replace(" ", "T"); - if (!settings.show.convertTimezone) { - rs = strip_tz_suffix(rs); - } - const runStart = new Date(rs); + const runStart = get_run_start_date(run); return runStart >= fromDateTime && runStart <= toDateTime; }); } @@ -464,6 +469,7 @@ export { get_custom_filter_value, get_hidden_custom_filters, get_project_version_value, + get_run_start_date, parse_custom_filters, remove_milliseconds, remove_timezones, diff --git a/robotframework_dashboard/js/information.js b/robotframework_dashboard/js/information.js index 24f9d897..de59e2cc 100644 --- a/robotframework_dashboard/js/information.js +++ b/robotframework_dashboard/js/information.js @@ -76,10 +76,12 @@ function setup_information_popups() { } // the tooltip hangs below its icon, so a long text on an icon near the bottom of the - // window runs off screen: flip it above the icon, and pin it to the top edge when even - // that does not fit (a tooltip taller than the window) + // window runs off screen: flip it above the icon, keep it inside the window (an icon + // that is itself below the fold would take the tooltip off screen with it) and pin it + // to the top edge when even that does not fit (a tooltip taller than the window) if (tooltipRect.bottom > window.innerHeight - padding) { - top = Math.max(rect.top - tooltipRect.height - 8, padding); + const lowestTop = window.innerHeight - tooltipRect.height - padding; + top = Math.max(Math.min(rect.top - tooltipRect.height - 8, lowestTop), padding); tooltipEl.style.top = `${top}px`; } } diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js index 7b3339f7..1854d3d3 100644 --- a/robotframework_dashboard/js/variables/information.js +++ b/robotframework_dashboard/js/variables/information.js @@ -160,6 +160,14 @@ It helps identify tests with inconsistent execution times, which might be flaky - A dot indicates the filter is active. - The number behind an option is how many runs remain if you select it (the other filters stay as they are). - Greyed out options match no runs, but can still be selected.`, + "filterDateHistogramInformation": `How many runs started in each time bucket, split by run status, for the date range below. +- Drag across the bars to select a range: the from/to date and time are filled in and the chart zooms into the selection, so dragging again zooms in further. +- Clicking a single bar selects that bucket. +- A selection always covers whole bars, and the text next to the chart says how much time one bar is. +- Hovering a bar shows its time range and one block per run with the tests that passed, failed and were skipped, the same way the run graphs do. +- 'Reset Range' (or widening the from/to values by hand) zooms back out to all runs. +- Every other filter shapes the bars, the date range itself does not: it is the part of the timeline you are looking at, so widening it brings the runs outside it back. +- The amount filter is not applied to the bars.`, "filterFromDateInformation": "Show only runs that started on or after this date.", "filterFromTimeInformation": "Show only runs that started at or after this time (combined with From Date).", "filterToDateInformation": "Show only runs that started on or before this date.", diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index 87f0e53b..c703edfd 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -563,52 +563,66 @@

Filters

-
- - - From Date - - -
- -
-
-
- - - From Time - - -
- +
+
+ + Runs over Time + + +
+ + +
-
-
- - - To Date - - -
- +
+ + +
+
-
- - - To Time - - -
- +
+
+
+ + From + + + + +
+
+ + + + + +
+
+ + To + + + + +
+
+ + + + + +
import('./mocks/data.js')); +vi.mock('@js/variables/globals.js', () => import('./mocks/globals.js')); +vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); +vi.mock('@js/variables/chartconfig.js', () => import('./mocks/chartconfig.js')); + +import { build_histogram_buckets, build_tooltip_content, get_run_status, pick_bucket_unit } from '@js/filter/date_histogram.js'; + +function make_run(run) { + return { name: 'run', run_start: '2026-01-01 10:00:00', passed: 1, failed: 0, skipped: 0, ...run }; +} + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +// the bucket unit of a window, as its label +function unit_label_for(spanMs) { + return pick_bucket_unit(spanMs).label; +} + +describe('pick_bucket_unit', () => { + it('picks the smallest unit that keeps the window at or below 60 bars', () => { + expect(unit_label_for(30 * MINUTE)).toBe('1 minute'); + expect(unit_label_for(4 * HOUR)).toBe('5 minutes'); + expect(unit_label_for(12 * HOUR)).toBe('15 minutes'); + expect(unit_label_for(2 * DAY)).toBe('1 hour'); + expect(unit_label_for(10 * DAY)).toBe('6 hours'); + expect(unit_label_for(45 * DAY)).toBe('1 day'); + expect(unit_label_for(365 * DAY)).toBe('1 week'); + }); + + it('falls back to the largest unit for a window no unit can cover', () => { + expect(unit_label_for(20 * 365 * DAY)).toBe('1 month'); + }); +}); + +describe('build_histogram_buckets', () => { + const hourUnit = pick_bucket_unit(2 * DAY); + const dayUnit = pick_bucket_unit(45 * DAY); + + it('starts the buckets on a unit boundary instead of on the window edge', () => { + const histogram = build_histogram_buckets([], new Date('2026-01-01T10:30:00'), new Date('2026-01-03T10:30:00'), dayUnit); + expect(histogram.buckets[0].start).toEqual(new Date('2026-01-01T00:00:00')); + expect(histogram.buckets[1].start).toEqual(new Date('2026-01-02T00:00:00')); + expect(histogram.buckets.at(-1).start).toEqual(new Date('2026-01-03T00:00:00')); + }); + + it('counts a run in the bucket it started in, split by run status', () => { + const runs = [ + make_run({ run_start: '2026-01-01 10:05:00', passed: 3, failed: 0, skipped: 0 }), + make_run({ run_start: '2026-01-01 10:55:00', passed: 2, failed: 1, skipped: 0 }), + make_run({ run_start: '2026-01-01 11:05:00', passed: 0, failed: 0, skipped: 4 }), + ]; + const histogram = build_histogram_buckets(runs, new Date('2026-01-01T10:00:00'), new Date('2026-01-01T12:00:00'), hourUnit); + expect(histogram.total).toBe(3); + expect(histogram.buckets[0]).toMatchObject({ passed: 1, failed: 1, skipped: 0 }); + expect(histogram.buckets[1]).toMatchObject({ passed: 0, failed: 0, skipped: 1 }); + }); + + it('leaves out the runs outside the window', () => { + const runs = [ + make_run({ run_start: '2026-01-01 09:00:00' }), + make_run({ run_start: '2026-01-01 10:30:00' }), + make_run({ run_start: '2026-01-01 13:00:00' }), + ]; + const histogram = build_histogram_buckets(runs, new Date('2026-01-01T10:00:00'), new Date('2026-01-01T12:00:00'), hourUnit); + expect(histogram.total).toBe(1); + expect(histogram.buckets[0].passed).toBe(1); + }); + + it('keeps a bucket per unit so the bars are evenly spaced in time', () => { + const histogram = build_histogram_buckets([], new Date('2026-01-01T00:00:00'), new Date('2026-01-01T05:00:00'), hourUnit); + expect(histogram.buckets).toHaveLength(6); + expect(histogram.buckets.every(bucket => bucket.passed === 0 && bucket.failed === 0 && bucket.skipped === 0)).toBe(true); + }); + + it('advances a day bucket by calendar date so a DST switch does not shift it', () => { + // the night of 2026-03-29 is 23 hours long in Central European Time + const histogram = build_histogram_buckets([], new Date('2026-03-28T12:00:00'), new Date('2026-03-31T12:00:00'), dayUnit); + for (const bucket of histogram.buckets) { + expect(bucket.start.getHours()).toBe(0); + } + }); +}); + +describe('get_run_status', () => { + it('calls any run with a failure failed', () => { + expect(get_run_status(make_run({ passed: 10, failed: 1, skipped: 0 }))).toBe('failed'); + }); + + it('calls a run that only skipped skipped', () => { + expect(get_run_status(make_run({ passed: 0, failed: 0, skipped: 3 }))).toBe('skipped'); + }); + + it('calls a run with passes and skips passed', () => { + expect(get_run_status(make_run({ passed: 2, failed: 0, skipped: 3 }))).toBe('passed'); + }); +}); + +describe('tooltip contents', () => { + const hourUnit = pick_bucket_unit(2 * DAY); + + function histogram_of(runs) { + return build_histogram_buckets(runs, new Date('2026-01-01T10:00:00'), new Date('2026-01-01T12:00:00'), hourUnit); + } + + it('holds a block per run with the three status rows', () => { + const histogram = histogram_of([ + make_run({ run_start: '2026-01-01 10:05:00', passed: 100, failed: 5, skipped: 3 }), + make_run({ run_start: '2026-01-01 10:45:00', name: 'api', passed: 50, failed: 0, skipped: 0 }), + ]); + const content = build_tooltip_content(histogram, 0); + expect(content.title).toBe('01.01.2026 10:00 - 10:59'); + expect(content.runs.map(run => run.label)).toEqual(['10:05 run', '10:45 api']); + expect(content.runs[0].rows.map(row => `${row.label}: ${row.value}`)) + .toEqual(['Failed: 5', 'Skipped: 3', 'Passed: 100']); + expect(content.runs[0].rows.every(row => row.color)).toBe(true); + }); + + it('sorts the runs of a bucket by start time', () => { + const histogram = histogram_of([ + make_run({ run_start: '2026-01-01 10:45:00', name: 'late' }), + make_run({ run_start: '2026-01-01 10:05:00', name: 'early' }), + ]); + expect(build_tooltip_content(histogram, 0).runs.map(run => run.label)).toEqual(['10:05 early', '10:45 late']); + }); + + it('cuts off a long bucket and counts what it left out', () => { + const runs = Array.from({ length: 9 }, (item, index) => + make_run({ run_start: `2026-01-01 10:0${index}:00`, name: `run_${index}` })); + const content = build_tooltip_content(histogram_of(runs), 0); + expect(content.runs).toHaveLength(6); + expect(content.hiddenRuns).toBe(3); + }); + + it('repeats the date of a run only when a bar covers more than a day', () => { + const weekUnit = pick_bucket_unit(365 * DAY); + const histogram = build_histogram_buckets( + [make_run({ run_start: '2026-01-07 09:30:00' })], + new Date('2026-01-05T00:00:00'), new Date('2026-01-11T00:00:00'), weekUnit); + expect(build_tooltip_content(histogram, 0).runs[0].label).toBe('07.01 09:30 run'); + }); + + it('keeps the time range of an empty bucket and leaves the blocks out', () => { + const content = build_tooltip_content(histogram_of([]), 0); + expect(content.title).toBe('01.01.2026 10:00 - 10:59'); + expect(content.runs).toEqual([]); + }); +}); diff --git a/tests/robot/resources/keywords/dashboard-keywords.resource b/tests/robot/resources/keywords/dashboard-keywords.resource index 5d5b03fd..fc6a1516 100644 --- a/tests/robot/resources/keywords/dashboard-keywords.resource +++ b/tests/robot/resources/keywords/dashboard-keywords.resource @@ -198,6 +198,63 @@ Set Date Filter IF '${toTime}' != '${None}' Type Text selector=id=toTime txt=${toTime} clear=True Close Filter Dialog +Wait For Date Histogram + [Documentation] The histogram is drawn on shown.bs.modal, which can fire a moment after + ... Open Filter Dialog is satisfied, and a filter change redraws it on the next frame. + Wait For Function () => !!window.dateHistogramChart timeout=10s + +Date Histogram Bucket Label Should Be + [Documentation] The "1 bar = " label next to the histogram: the bucket size the + ... current window was drawn with. + [Arguments] ${label} + Wait For Date Histogram + Browser.Get Text selector=id=dateHistogramBucket assertion_operator=== assertion_expected=${label} + +Get Date Histogram Run Count + [Documentation] Every bar of every status added up: the runs the bars cover. + Wait For Date Histogram + ${total} Evaluate JavaScript ${None} + ... () => window.dateHistogramChart.data.datasets.reduce((sum, dataset) => sum + dataset.data.reduce((a, b) => a + b, 0), 0) + RETURN ${total} + +Date Histogram Should Hold ${runs} Runs + ${total} Get Date Histogram Run Count + Should Be Equal As Integers ${total} ${runs} + +Date Histogram Pixel At + [Documentation] The page coordinates of a fraction of the plotted chart area, so a gesture + ... aims at the bars instead of at the axis labels and the padding around them. + [Arguments] ${fraction} + ${box} Get BoundingBox selector=id=dateHistogramGraph + ${area} Evaluate JavaScript ${None} + ... () => { const area = window.dateHistogramChart.chartArea; return { left: area.left, right: area.right, top: area.top, bottom: area.bottom }; } + ${x} Evaluate ${box}[x] + ${area}[left] + (${area}[right] - ${area}[left]) * ${fraction} + ${y} Evaluate ${box}[y] + (${area}[top] + ${area}[bottom]) / 2 + RETURN ${x} ${y} + +Drag On Date Histogram + [Documentation] Drags across part of the histogram, which selects that part of the window and + ... zooms into it. + [Arguments] ${from_fraction} ${to_fraction} + Wait For Date Histogram + ${from_x} ${y} Date Histogram Pixel At ${from_fraction} + ${to_x} ${y} Date Histogram Pixel At ${to_fraction} + Mouse Move ${from_x} ${y} + Mouse Button down + Mouse Move ${to_x} ${y} steps=10 + Mouse Button up + +Click On Highest Date Histogram Bar + [Documentation] A click without a drag selects the single bucket under the pointer. The + ... fullest bar is the one to aim at: a bucket without runs leaves nothing to draw. + Wait For Date Histogram + ${fraction} Evaluate JavaScript ${None} + ... () => { const chart = window.dateHistogramChart; const totals = chart.data.labels.map((label, index) => chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[index], 0)); return (totals.indexOf(Math.max(...totals)) + 0.5) / totals.length; } + ${x} ${y} Date Histogram Pixel At ${fraction} + Mouse Move ${x} ${y} + Mouse Button down + Mouse Button up + Set Amount Filter [Arguments] ${amount} ${close_filter_dialog}=True Open Filter Dialog diff --git a/tests/robot/testsuites/06_filters.robot b/tests/robot/testsuites/06_filters.robot index 35ea778b..bdc3b6d2 100644 --- a/tests/robot/testsuites/06_filters.robot +++ b/tests/robot/testsuites/06_filters.robot @@ -77,6 +77,55 @@ Validate Dashboard Date Filter Set Date Filter fromDate=08252026 fromTime=1200am Validate Component id=runStatisticsSection name=runDateFilter folder=run +Validate Date Histogram Bars + Open Filter Dialog + Date Histogram Bucket Label Should Be 1 bar = 1 day + Date Histogram Should Hold 18 Runs + ${datasets} Get Graph Dataset Labels dateHistogramChart + Should Be Equal ${datasets} ${{ ['Failed', 'Skipped', 'Passed'] }} + Close Filter Dialog + +Validate Date Histogram Drag Zooms Into The Selection + Open Filter Dialog + Drag On Date Histogram 0.1 0.35 + # a quarter of the 24 day span is about a week, which is drawn with 6 hour buckets + Date Histogram Bucket Label Should Be 1 bar = 6 hours + # the drag happened while the bars were days, so the range snapped to whole days + Get Property selector=id=fromTime property=value assertion_operator=== assertion_expected=00:00 + Get Property selector=id=toTime property=value assertion_operator=== assertion_expected=23:59 + ${runs} Get Date Histogram Run Count + Close Filter Dialog + Should Show ${runs} Of ${runs} Runs + +Validate Date Histogram Bar Click Selects One Bucket + Open Filter Dialog + Click On Highest Date Histogram Bar + Date Histogram Bucket Label Should Be 1 bar = 1 hour + ${fromDate} Get Property selector=id=fromDate property=value + ${toDate} Get Property selector=id=toDate property=value + Should Be Equal ${fromDate} ${toDate} + Get Property selector=id=fromTime property=value assertion_operator=== assertion_expected=00:00 + Get Property selector=id=toTime property=value assertion_operator=== assertion_expected=23:59 + Close Filter Dialog + +Validate Date Histogram Reset Range Button + Open Filter Dialog + Drag On Date Histogram 0.1 0.35 + Date Histogram Bucket Label Should Be 1 bar = 6 hours + Click selector=id=dateHistogramReset + # the runs outside the zoomed window are back, which they can only be because the bars are + # built without the date filter + Date Histogram Bucket Label Should Be 1 bar = 1 day + Date Histogram Should Hold 18 Runs + Close Filter Dialog + Should Show 18 Of 18 Runs + +Validate Date Histogram Follows The Other Filters + Set Run Tags Filter project_2 + Open Filter Dialog + Date Histogram Should Hold 8 Runs + Close Filter Dialog + Validate Dashboard Amount Filter Set Amount Filter amount=5 Validate Component id=runStatisticsSection name=runAmountFilter folder=run