From cdaf51cfa4d771022e04a94d64f201d382b34ea6 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 26 Sep 2026 01:18:43 +0200 Subject: [PATCH 1/2] feat: drag to select time histogram for the date filter (#356) The date range was four static inputs, which nobody uses to zoom in with: you cannot see where the runs are, so there is nothing to aim at. The filter modal now opens with a bar chart of the runs per time bucket above the range, the way Grafana and Kibana let you pick a period, and the four inputs sit below it as one horizontal row. Dragging across the bars fills the inputs and zooms the chart into the selection, re-bucketing finer, so dragging again drills further down. Clicking one bar selects that bucket, Reset Range goes back to the full span. The window is not state of its own: it is derived from the inputs on every render, so filter profiles and the merge dialogue carry the zoom without knowing that a chart exists. The bars follow every other filter but not the date range itself, through a new "dates" facet of apply_filters_except(), so widening the range brings the runs outside the window straight back. Hovering a bar gives a block per run with the three status rows the run graphs show. No new dependency: Chart.js and the date-fns adapter are already bundled. The brush is pointer handling over the category scale, the bucket separators are drawn by a small inline plugin because Chart.js only puts a gridline under a tick that carries a label, and the tooltip is an element because the one on the canvas is clipped by the chart and paints every line in a single colour. Two things that bundling makes easy to get wrong and that cost a debug round each: an element id is a property of window, so the chart may not be kept under the id of its canvas, and every module ends up in one script, so the new date parser could not be called parse_run_start - the one in common.js parses differently and the overview page broke on it. Also fixes the information popups. They flip above their icon when they would run off the bottom of the window, which still leaves the window when the icon is itself below the fold; the extra filter row made that reachable. They are now clamped to the window. Tests: 5 robot tests in 06_filters, 18 JavaScript tests. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/filtering.md | 24 +- .../css/components/02-filters.css | 90 ++++ .../js/eventlisteners/filter_modal.js | 13 + .../js/filter/availability.js | 14 +- .../js/filter/date_histogram.js | 488 ++++++++++++++++++ .../js/filter/pipeline.js | 20 +- robotframework_dashboard/js/information.js | 8 +- .../js/variables/information.js | 8 + .../templates/dashboard.html | 100 ++-- setup.py | 2 +- tests/javascript/date_histogram.test.js | 153 ++++++ .../keywords/dashboard-keywords.resource | 57 ++ tests/robot/testsuites/06_filters.robot | 49 ++ 14 files changed, 963 insertions(+), 65 deletions(-) create mode 100644 robotframework_dashboard/js/filter/date_histogram.js create mode 100644 tests/javascript/date_histogram.test.js diff --git a/README.md b/README.md index 02a1c09a..b4fcf71e 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, the way Grafana and Kibana do. 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..6f23c1c6 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 way Grafana and Kibana let you pick a time range. -#### 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 From e2cfc648936f1bcef969412cc4cc2b5e26ae4eee Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Sat, 26 Sep 2026 01:28:24 +0200 Subject: [PATCH 2/2] docs: describe the date histogram without naming other tools Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/filtering.md | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4fcf71e..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). Drag across the runs-over-time histogram to zoom into a period, the way Grafana and Kibana do. 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 6f23c1c6..7764d8e4 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -76,7 +76,7 @@ Example: with two custom filters where `filter_1=A` only ever occurs together wi #### 4. Runs over Time (date histogram) -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 way Grafana and Kibana let you pick a time range. +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. - **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). diff --git a/setup.py b/setup.py index 737af6d9..769fa7b7 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ - 📄 **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. Drag across the runs-over-time histogram to zoom into a period, the way Grafana and Kibana do. 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. 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. - 🛠️ **Custom Database Classes** - Extend or replace the database processor for custom backends.