diff --git a/backend/kernelCI/settings.py b/backend/kernelCI/settings.py index 1e99ef98d..5cbc481ac 100644 --- a/backend/kernelCI/settings.py +++ b/backend/kernelCI/settings.py @@ -227,6 +227,10 @@ def get_json_env_var(name, default): "--monitoring-id=delete_unused_hardware_status", ], ), + ( + "10 0 * * 6", + "kernelCI_app.queries.notifications.warm_metrics_cache", + ), ( "0 0 * * 6", "django.core.management.call_command", diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index 5267cc101..9af0ea4cf 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -71,10 +71,10 @@ class DocStrings: ) DEFAULT_INTERVAL_DESCRIPTION = "Interval in days for the listing" METRICS_START_DAYS_AGO_DESCRIPTION = ( - "Number of days ago that marks the start of the metrics interval" + "Inclusive UTC day offset for the start of a [start, end) metrics interval" ) METRICS_END_DAYS_AGO_DESCRIPTION = ( - "Number of days ago that marks the end of the metrics interval" + "Exclusive UTC day offset for the end of a [start, end) metrics interval" ) DEFAULT_LISTING_STARTING_DATE_DESCRIPTION = ( "Starting date to calculate the search interval." diff --git a/backend/kernelCI_app/management/commands/notifications.py b/backend/kernelCI_app/management/commands/notifications.py index 81f7cabf6..3a5fb538f 100644 --- a/backend/kernelCI_app/management/commands/notifications.py +++ b/backend/kernelCI_app/management/commands/notifications.py @@ -1,7 +1,7 @@ import json import sys from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, time, timedelta, timezone from email.utils import make_msgid from types import SimpleNamespace from typing import Optional @@ -38,6 +38,7 @@ from kernelCI_app.queries.notifications import ( get_checkout_summary_data, get_metrics_data, + interval_params, kcidb_build_incidents, kcidb_issue_details, kcidb_last_build_without_issue, @@ -847,8 +848,14 @@ def generate_metrics_report( return now = datetime.now(timezone.utc) - start_datetime = now - timedelta(days=start_days_ago) - end_datetime = now - timedelta(days=end_days_ago) + bounds = interval_params(start_days_ago, end_days_ago) + start_datetime = datetime.fromisoformat(bounds["start_date"]) + exclusive_end = datetime.fromisoformat(bounds["end_date"]) + end_datetime = datetime.combine( + exclusive_end.date() - timedelta(days=1), + time.max, + tzinfo=timezone.utc, + ) data: MetricsReportData = get_metrics_data( start_days_ago=start_days_ago, diff --git a/backend/kernelCI_app/queries/notifications.py b/backend/kernelCI_app/queries/notifications.py index 86454064e..d9527df2f 100644 --- a/backend/kernelCI_app/queries/notifications.py +++ b/backend/kernelCI_app/queries/notifications.py @@ -1,5 +1,6 @@ import sys from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, time, timedelta, timezone from typing import Any from django.db import connection, connections @@ -16,7 +17,9 @@ TopIssue, ) +METRICS_CACHE_WARM_PERIODS = (7, 14) METRICS_CACHE_TIMEOUT = 60 * 60 * 6 # 6 hours +METRICS_CACHE_WARM_TIMEOUT = 60 * 60 * 24 * 8 # 8 days def kcidb_execute_query(query, params=None): @@ -698,17 +701,24 @@ def query_fetchone_work( query: str, params: dict[str, Any], timeout: int = METRICS_CACHE_TIMEOUT, -): - rows = get_query_cache(key=cache_key, params=params) - if rows is not None: - return rows + use_cache: bool = True, +) -> Any: + if use_cache: + cached = get_query_cache(key=cache_key, params=params) + if cached is not None: + return cached try: with connections["default"].cursor() as cursor: cursor.execute(query, params) rows = cursor.fetchone() finally: connections["default"].close() - set_query_cache(key=cache_key, params=params, rows=rows, timeout=timeout) + set_query_cache( + key=cache_key, + params=params, + rows=rows, + timeout=timeout, + ) return rows @@ -718,24 +728,50 @@ def query_fetchall_work( query: str, params: dict[str, Any], timeout: int = METRICS_CACHE_TIMEOUT, -): - rows = get_query_cache(key=cache_key, params=params) - if rows is not None: - return rows + use_cache: bool = True, +) -> Any: + if use_cache: + cached = get_query_cache(key=cache_key, params=params) + if cached is not None: + return cached try: with connections["default"].cursor() as cursor: cursor.execute(query, params) rows = cursor.fetchall() finally: connections["default"].close() - set_query_cache(key=cache_key, params=params, rows=rows, timeout=timeout) + set_query_cache( + key=cache_key, + params=params, + rows=rows, + timeout=timeout, + ) return rows +def interval_params(start_days_ago: int, end_days_ago: int) -> dict[str, str]: + """Build [start_date, end_date) bounds from UTC day offsets.""" + today = datetime.now(timezone.utc).date() + start_datetime = datetime.combine( + today - timedelta(days=start_days_ago), time.min, tzinfo=timezone.utc + ) + end_datetime = datetime.combine( + today - timedelta(days=end_days_ago), + time.min, + tzinfo=timezone.utc, + ) + return { + "start_date": start_datetime.isoformat(), + "end_date": end_datetime.isoformat(), + } + + def get_metrics_data( *, start_days_ago: int, end_days_ago: int, + use_cache: bool = True, + cache_timeout: int = METRICS_CACHE_TIMEOUT, ) -> MetricsReportData: if start_days_ago > 30: @@ -747,41 +783,37 @@ def get_metrics_data( prev_start_days_ago = start_days_ago - period_length prev_end_days_ago = start_days_ago - params = { - "start_days_ago": str(start_days_ago) + " days", - "end_days_ago": str(end_days_ago) + " days", - } - prev_params = { - "start_days_ago": str(prev_start_days_ago) + " days", - "end_days_ago": str(prev_end_days_ago) + " days", - } + params = interval_params(start_days_ago, end_days_ago) + prev_params = interval_params(prev_start_days_ago, prev_end_days_ago) total_objects_query = """ SELECT - (SELECT COUNT(DISTINCT tree_name) FROM checkouts WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) - AS n_trees, - (SELECT COUNT(*) FROM checkouts WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) - AS n_checkouts, - (SELECT COUNT(*) FROM builds WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) + c.n_trees, + c.n_checkouts, + (SELECT COUNT(*) FROM builds WHERE _timestamp >= + %(start_date)s::timestamptz + AND _timestamp < %(end_date)s::timestamptz) AS n_builds, - (SELECT COUNT(*) FROM tests WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) + (SELECT COUNT(*) FROM tests WHERE _timestamp >= + %(start_date)s::timestamptz + AND _timestamp < %(end_date)s::timestamptz) AS n_tests, - (SELECT COUNT(*) FROM issues WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) + (SELECT COUNT(*) FROM issues WHERE _timestamp >= + %(start_date)s::timestamptz + AND _timestamp < %(end_date)s::timestamptz) AS n_issues, - (SELECT COUNT(*) FROM incidents WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s) - AS n_incidents; + (SELECT COUNT(*) FROM incidents WHERE _timestamp >= + %(start_date)s::timestamptz + AND _timestamp < %(end_date)s::timestamptz) + AS n_incidents + FROM ( + SELECT + COUNT(DISTINCT tree_name) AS n_trees, + COUNT(*) AS n_checkouts + FROM checkouts + WHERE _timestamp >= %(start_date)s::timestamptz + AND _timestamp < %(end_date)s::timestamptz + ) c; """ build_incidents_query = """ @@ -800,20 +832,14 @@ def get_metrics_data( SELECT origin, COUNT(*) FILTER ( - WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + WHERE _timestamp >= %(start_date)s::timestamptz AND _timestamp < %(end_date)s::timestamptz ) AS total_incidents, COUNT(*) FILTER ( - WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + WHERE _timestamp >= %(start_date)s::timestamptz AND _timestamp < %(end_date)s::timestamptz AND rn = 1 ) AS n_new_issues, COUNT(DISTINCT issue_id) FILTER ( - WHERE _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + WHERE _timestamp >= %(start_date)s::timestamptz AND _timestamp < %(end_date)s::timestamptz ) AS n_issues FROM time_rank GROUP BY origin @@ -830,9 +856,9 @@ def get_metrics_data( JOIN issues i ON inc.issue_id = i.id AND inc.issue_version = i.version WHERE inc.build_id is not null - AND inc._timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + AND inc._timestamp >= + %(start_date)s::timestamptz + AND inc._timestamp < %(end_date)s::timestamptz GROUP BY inc.origin, inc.issue_id, inc.issue_version, i.comment ORDER BY inc.origin, total DESC ), @@ -874,9 +900,7 @@ def get_metrics_data( SELECT issue_id, origin FROM time_rank WHERE rn = 1 - AND _timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + AND _timestamp >= %(start_date)s::timestamptz AND _timestamp < %(end_date)s::timestamptz ) SELECT inc.origin, @@ -889,9 +913,9 @@ def get_metrics_data( JOIN new_issues ni ON inc.issue_id = ni.issue_id AND inc.origin = ni.origin WHERE inc.build_id IS NOT NULL - AND inc._timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + AND inc._timestamp >= + %(start_date)s::timestamptz + AND inc._timestamp < %(end_date)s::timestamptz GROUP BY inc.origin, inc.issue_id, inc.issue_version, i.comment ORDER BY inc.origin, total DESC """ @@ -906,9 +930,9 @@ def get_metrics_data( FROM tests t WHERE t.misc->>'runtime' IS NOT NULL - AND t._timestamp BETWEEN - NOW() - INTERVAL %(start_days_ago)s - AND NOW() - INTERVAL %(end_days_ago)s + AND t._timestamp >= + %(start_date)s::timestamptz + AND t._timestamp < %(end_date)s::timestamptz GROUP BY lab """ @@ -918,36 +942,48 @@ def get_metrics_data( cache_key="metricsTotalObjects", query=total_objects_query, params=params, + use_cache=use_cache, + timeout=cache_timeout, ) prev_total_objects_result = executor.submit( query_fetchone_work, cache_key="metricsTotalObjects", query=total_objects_query, params=prev_params, + use_cache=use_cache, + timeout=cache_timeout, ) build_incidents_result = executor.submit( query_fetchall_work, cache_key="metricsBuildIncidents", query=build_incidents_query, params=params, + use_cache=use_cache, + timeout=cache_timeout, ) new_build_issues_result = executor.submit( query_fetchall_work, cache_key="metricsNewBuildIssues", query=new_build_issues_query, params=params, + use_cache=use_cache, + timeout=cache_timeout, ) lab_summary_results = executor.submit( query_fetchall_work, cache_key="metricsLabSummary", query=lab_summary_query, params=params, + use_cache=use_cache, + timeout=cache_timeout, ) prev_lab_summary_results = executor.submit( query_fetchall_work, cache_key="metricsLabSummary", query=lab_summary_query, params=prev_params, + use_cache=use_cache, + timeout=cache_timeout, ) total_objects_result = total_objects_result.result() @@ -1029,3 +1065,22 @@ def get_metrics_data( raise e return data + + +def warm_metrics_cache() -> None: + for period_days in METRICS_CACHE_WARM_PERIODS: + out( + "Warming metrics cache for " + f"{period_days}-day period " + f"(start_days_ago={period_days}, end_days_ago=0)" + ) + try: + get_metrics_data( + start_days_ago=period_days, + end_days_ago=0, + use_cache=False, + cache_timeout=METRICS_CACHE_WARM_TIMEOUT, + ) + out(f"Warmed metrics cache for {period_days}-day period") + except Exception as e: + out(f"Failed to warm metrics cache for {period_days}-day period: {e}") diff --git a/backend/kernelCI_app/tests/factories/build_factory.py b/backend/kernelCI_app/tests/factories/build_factory.py index 57af355f6..6afd85a09 100644 --- a/backend/kernelCI_app/tests/factories/build_factory.py +++ b/backend/kernelCI_app/tests/factories/build_factory.py @@ -3,13 +3,13 @@ """ import factory -from django.utils import timezone from factory.django import DjangoModelFactory from kernelCI_app.models import Builds, StatusChoices from .checkout_factory import CheckoutFactory from .mocks import Build, Checkout +from .seed_time import seeded_timestamp_for_index class BuildFactory(DjangoModelFactory): @@ -93,4 +93,4 @@ class Meta: ) ) - field_timestamp = factory.LazyFunction(lambda: timezone.now()) + field_timestamp = factory.Sequence(seeded_timestamp_for_index) diff --git a/backend/kernelCI_app/tests/factories/checkout_factory.py b/backend/kernelCI_app/tests/factories/checkout_factory.py index d7d158419..c237fd1c3 100644 --- a/backend/kernelCI_app/tests/factories/checkout_factory.py +++ b/backend/kernelCI_app/tests/factories/checkout_factory.py @@ -9,6 +9,7 @@ from kernelCI_app.models import Checkouts from .mocks import Checkout +from .seed_time import seeded_timestamp_for_index class CheckoutFactory(DjangoModelFactory): @@ -137,4 +138,4 @@ class Meta: lambda: timezone.now() - timezone.timedelta(minutes=15) ) - field_timestamp = factory.LazyFunction(lambda: timezone.now()) + field_timestamp = factory.Sequence(seeded_timestamp_for_index) diff --git a/backend/kernelCI_app/tests/factories/incident_factory.py b/backend/kernelCI_app/tests/factories/incident_factory.py index 482137444..ff38b8e16 100644 --- a/backend/kernelCI_app/tests/factories/incident_factory.py +++ b/backend/kernelCI_app/tests/factories/incident_factory.py @@ -10,6 +10,7 @@ from .build_factory import BuildFactory from .issue_factory import IssueFactory +from .seed_time import seeded_timestamp_for_index from .test_factory import TestFactory @@ -42,4 +43,4 @@ class Meta: } ) - field_timestamp = factory.LazyFunction(lambda: timezone.now()) + field_timestamp = factory.Sequence(seeded_timestamp_for_index) diff --git a/backend/kernelCI_app/tests/factories/issue_factory.py b/backend/kernelCI_app/tests/factories/issue_factory.py index 135e8260b..8991fec59 100644 --- a/backend/kernelCI_app/tests/factories/issue_factory.py +++ b/backend/kernelCI_app/tests/factories/issue_factory.py @@ -3,12 +3,12 @@ """ import factory -from django.utils import timezone from factory.django import DjangoModelFactory from kernelCI_app.models import Issues from .mocks import Issue +from .seed_time import seeded_timestamp_for_index class IssueFactory(DjangoModelFactory): @@ -59,4 +59,4 @@ class Meta: } ) - field_timestamp = factory.LazyFunction(lambda: timezone.now()) + field_timestamp = factory.Sequence(seeded_timestamp_for_index) diff --git a/backend/kernelCI_app/tests/factories/seed_time.py b/backend/kernelCI_app/tests/factories/seed_time.py new file mode 100644 index 000000000..8ef7d9c73 --- /dev/null +++ b/backend/kernelCI_app/tests/factories/seed_time.py @@ -0,0 +1,18 @@ +from datetime import datetime, time, timedelta, timezone + +from django.utils import timezone as django_timezone + +# Seeded rows land at UTC midnight on days_ago 0..SEED_DAY_SPAN-1 so that both the +# default (7, 0) current window and the (14, 7) previous window contain data. +SEED_DAY_SPAN = 22 + + +def seeded_timestamp(*, days_ago: int = 1) -> datetime: + """UTC midnight for seeded data, sitting exactly on interval_params day boundaries.""" + day = django_timezone.now().date() - timedelta(days=days_ago) + return datetime.combine(day, time.min, tzinfo=timezone.utc) + + +def seeded_timestamp_for_index(n: int) -> datetime: + """Cycle a factory Sequence index across SEED_DAY_SPAN consecutive midnights.""" + return seeded_timestamp(days_ago=n % SEED_DAY_SPAN) diff --git a/backend/kernelCI_app/tests/factories/test_factory.py b/backend/kernelCI_app/tests/factories/test_factory.py index 258e9a2e4..7e1df2b64 100644 --- a/backend/kernelCI_app/tests/factories/test_factory.py +++ b/backend/kernelCI_app/tests/factories/test_factory.py @@ -3,13 +3,13 @@ """ import factory -from django.utils import timezone from factory.django import DjangoModelFactory from kernelCI_app.models import StatusChoices, Tests from .build_factory import BuildFactory from .mocks import Checkout, Test +from .seed_time import seeded_timestamp_for_index class TestFactory(DjangoModelFactory): @@ -95,4 +95,4 @@ class Meta: ) ) - field_timestamp = factory.LazyFunction(lambda: timezone.now()) + field_timestamp = factory.Sequence(seeded_timestamp_for_index) diff --git a/backend/kernelCI_app/tests/integrationTests/metrics_test.py b/backend/kernelCI_app/tests/integrationTests/metrics_test.py index e0b6a483b..ec9defb28 100644 --- a/backend/kernelCI_app/tests/integrationTests/metrics_test.py +++ b/backend/kernelCI_app/tests/integrationTests/metrics_test.py @@ -30,30 +30,45 @@ "prev_lab_maps", ] -metrics_expected_counts = { - "n_trees": 11, - "n_checkouts": 58, - "n_builds": 28, - "n_tests": 30, - "n_issues": 20, - "n_incidents": 8, - "prev_n_trees": 0, - "prev_n_checkouts": 0, - "prev_n_builds": 0, - "prev_n_tests": 0, -} +# Plain COUNT(*) fields, additive across disjoint day windows. n_trees is +# COUNT(DISTINCT tree_name), so it does not add up and is excluded here. +additive_count_fields = [ + "n_checkouts", + "n_builds", + "n_tests", + "n_issues", + "n_incidents", +] -def test_get_metrics(): - response = client.get_metrics() +def _ok_content(query: dict[str, str] | None = None) -> dict: + response = client.get_metrics(query=query) content = string_to_json(response.content.decode()) - assert_status_code_and_error_response( response=response, content=content, status_code=HTTPStatus.OK, should_error=False, ) + return content + + +metrics_expected_counts = { + "n_trees": 5, + "n_checkouts": 21, + "n_builds": 12, + "n_tests": 14, + "n_issues": 7, + "n_incidents": 7, + "prev_n_trees": 6, + "prev_n_checkouts": 20, + "prev_n_builds": 7, + "prev_n_tests": 7, +} + + +def test_get_metrics(): + content = _ok_content() assert_has_fields_in_response_content( fields=metrics_expected_fields, response_content=content @@ -65,6 +80,28 @@ def test_get_metrics(): ) +def test_metrics_half_open_windows_tile_exactly(): + """Adjacent half-open windows [start, end) tile without gaps or overlap: + (2, 0) == (1, 0) + (2, 1) for additive counts.""" + day_1 = _ok_content({"start_days_ago": "1", "end_days_ago": "0"}) + day_2 = _ok_content({"start_days_ago": "2", "end_days_ago": "1"}) + both_days = _ok_content({"start_days_ago": "2", "end_days_ago": "0"}) + + for field in additive_count_fields: + assert both_days[field] == day_1[field] + day_2[field], ( + f"{field}: (2,0)={both_days[field]} != " + f"(1,0)={day_1[field]} + (2,1)={day_2[field]}" + ) + + +def test_metrics_window_outside_seeded_range_is_empty(): + """A window entirely beyond the seeded days returns zero counts.""" + content = _ok_content({"start_days_ago": "30", "end_days_ago": "22"}) + + for field in additive_count_fields: + assert content[field] == 0, f"{field}: api={content[field]} expected=0" + + @pytest.mark.parametrize( "query, status_code, has_error_body", [ diff --git a/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py b/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py index fcb34fb35..f44baa401 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/metrics_queries_test.py @@ -1,18 +1,23 @@ +from datetime import datetime, timezone from unittest.mock import patch -from kernelCI_app.queries.notifications import get_metrics_data +from kernelCI_app.queries.notifications import get_metrics_data, interval_params MODULE = "kernelCI_app.queries.notifications" EMPTY_TOTALS = (0, 0, 0, 0, 0, 0) +FIXED_NOW = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) class TestGetMetricsDataIntervals: @patch(f"{MODULE}.query_fetchall_work") @patch(f"{MODULE}.query_fetchone_work") + @patch(f"{MODULE}.datetime") def test_builds_current_and_previous_intervals_default_window( - self, mock_fetchone, mock_fetchall + self, mock_datetime, mock_fetchone, mock_fetchall ): + mock_datetime.now.return_value = FIXED_NOW + mock_datetime.combine.side_effect = datetime.combine mock_fetchone.return_value = EMPTY_TOTALS mock_fetchall.return_value = [] @@ -25,8 +30,8 @@ def test_builds_current_and_previous_intervals_default_window( mock_call.kwargs["params"] for mock_call in mock_fetchall.call_args_list ] - curr = {"start_days_ago": "7 days", "end_days_ago": "0 days"} - prev = {"start_days_ago": "14 days", "end_days_ago": "7 days"} + curr = interval_params(7, 0) + prev = interval_params(14, 7) assert curr in fetchone_params assert prev in fetchone_params @@ -35,7 +40,12 @@ def test_builds_current_and_previous_intervals_default_window( @patch(f"{MODULE}.query_fetchall_work") @patch(f"{MODULE}.query_fetchone_work") - def test_builds_custom_offset_window(self, mock_fetchone, mock_fetchall): + @patch(f"{MODULE}.datetime") + def test_builds_custom_offset_window( + self, mock_datetime, mock_fetchone, mock_fetchall + ): + mock_datetime.now.return_value = FIXED_NOW + mock_datetime.combine.side_effect = datetime.combine mock_fetchone.return_value = EMPTY_TOTALS mock_fetchall.return_value = [] @@ -48,10 +58,24 @@ def test_builds_custom_offset_window(self, mock_fetchone, mock_fetchall): mock_call.kwargs["params"] for mock_call in mock_fetchall.call_args_list ] - curr = {"start_days_ago": "14 days", "end_days_ago": "7 days"} - prev = {"start_days_ago": "21 days", "end_days_ago": "14 days"} + curr = interval_params(14, 7) + prev = interval_params(21, 14) assert curr in fetchone_params assert prev in fetchone_params assert curr in fetchall_params assert prev in fetchall_params + + +class TestIntervalParams: + @patch(f"{MODULE}.datetime") + def test_builds_half_open_interval(self, mock_datetime): + mock_datetime.now.return_value = datetime( + 2026, 6, 18, 12, 0, tzinfo=timezone.utc + ) + mock_datetime.combine.side_effect = datetime.combine + + params = interval_params(12, 4) + + assert params["start_date"] == "2026-06-06T00:00:00+00:00" + assert params["end_date"] == "2026-06-14T00:00:00+00:00" diff --git a/dashboard/src/api/metrics.ts b/dashboard/src/api/metrics.ts index 7c7aef298..3b9ca21e9 100644 --- a/dashboard/src/api/metrics.ts +++ b/dashboard/src/api/metrics.ts @@ -24,16 +24,11 @@ export const fetchMetrics = async ({ }; export const useMetrics = ({ - intervalInDays, -}: { - intervalInDays: number; -}): UseQueryResult => { + startDaysAgo, + endDaysAgo, +}: FetchMetricsParams): UseQueryResult => { return useQuery({ - queryKey: ['metrics', intervalInDays], - queryFn: () => - fetchMetrics({ - startDaysAgo: intervalInDays, - endDaysAgo: 0, - }), + queryKey: ['metrics', startDaysAgo, endDaysAgo], + queryFn: () => fetchMetrics({ startDaysAgo, endDaysAgo }), }); }; diff --git a/dashboard/src/components/InputTime/InputTime.tsx b/dashboard/src/components/InputTime/InputTime.tsx index fc18594d7..cb2d5e311 100644 --- a/dashboard/src/components/InputTime/InputTime.tsx +++ b/dashboard/src/components/InputTime/InputTime.tsx @@ -18,7 +18,10 @@ import DebounceInput from '@/components/DebounceInput/DebounceInput'; import type { PossibleMonitorPath } from '@/types/general'; import { DEFAULT_TIME_SEARCH } from '@/utils/constants/general'; -type PossibleIntervalPath = Exclude; +type PossibleIntervalPath = Exclude< + PossibleMonitorPath, + '/issues' | '/metrics' +>; const TOAST_TIMEOUT = 3000; diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index d4fc7a543..da8868820 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -286,6 +286,9 @@ export const messages = { 'This is the new, optimized version of the {page}. If you find any bugs, please file a {gitHubLink} and you can still access the old version {oldVersionLink}. Please note that some historical data might be missing, but it should be updated with recent data.', 'messages.olderPageVersion': 'This is the legacy version of the {page}, please refer to the new, optimized version {newPageLink}. If you find any bugs or divergences, please report to {gitHubLink}.', + 'metricsPage.period.previousTwoWeeks': 'Last 2 weeks', + 'metricsPage.period.previousWeek': 'Last week', + 'metricsPage.periodLabel': 'Period:', 'routes.buildDetails': 'Build', 'routes.hardwareMonitor': 'Hardware', 'routes.hardwareNewMonitor': 'Hardware New', diff --git a/dashboard/src/pages/Metrics/MetricsPage.tsx b/dashboard/src/pages/Metrics/MetricsPage.tsx index c0608d384..76b799597 100644 --- a/dashboard/src/pages/Metrics/MetricsPage.tsx +++ b/dashboard/src/pages/Metrics/MetricsPage.tsx @@ -1,8 +1,9 @@ import { Fragment, useState, type JSX } from 'react'; -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import { ArrowRightIcon } from 'lucide-react'; +import { useIntl } from 'react-intl'; import { ChevronRightAnimate } from '@/components/AnimatedIcons/Chevron'; @@ -20,16 +21,16 @@ import { } from '@/components/ui/table'; import { cn } from '@/lib/utils'; - type PeriodOption = { - label: string; + labelId: + | 'metricsPage.period.previousWeek' + | 'metricsPage.period.previousTwoWeeks'; days: number; }; const PERIOD_OPTIONS: PeriodOption[] = [ - { label: '7 days', days: 7 }, - { label: '14 days', days: 14 }, - { label: '30 days', days: 30 }, + { labelId: 'metricsPage.period.previousWeek', days: 7 }, + { labelId: 'metricsPage.period.previousTwoWeeks', days: 14 }, ]; type CoverageMetric = { @@ -70,6 +71,7 @@ function formatNumber(n: number): string { const PERCENTAGE_BASE = 100; const DEFAULT_INTERVAL_DAYS = 7; +const DAYS_IN_WEEK = 7; const getCoverageMetrics = (data: MetricsResponse): CoverageMetric[] => { return [ @@ -198,9 +200,13 @@ function PeriodSelector({ activeDays: number; onChange: (days: number) => void; }): JSX.Element { + const { formatMessage } = useIntl(); + return (
- Period: + + {formatMessage({ id: 'metricsPage.periodLabel' })} + {PERIOD_OPTIONS.map(opt => ( ))}
@@ -542,13 +548,22 @@ function LabsSection({ labs }: { labs: LabData[] }): JSX.Element { ); } +const getMetricsQueryParams = ( + activeDays: number, +): { startDaysAgo: number; endDaysAgo: number } => { + const today = new Date(); + const weekEndDaysAgo = (today.getUTCDay() + 1) % DAYS_IN_WEEK; + const endDaysAgo = Math.max(weekEndDaysAgo - 1, 0); + const startDaysAgo = endDaysAgo + activeDays; + return { startDaysAgo, endDaysAgo }; +}; + export const MetricsPage = (): JSX.Element => { - const { intervalInDays } = useSearch({ from: '/_main/metrics' }); - const navigate = useNavigate(); + const [activeDays, setActiveDays] = useState(DEFAULT_INTERVAL_DAYS); - const activeDays = intervalInDays ?? DEFAULT_INTERVAL_DAYS; + const { startDaysAgo, endDaysAgo } = getMetricsQueryParams(activeDays); - const { status, data, error } = useMetrics({ intervalInDays: activeDays }); + const { status, data, error } = useMetrics({ startDaysAgo, endDaysAgo }); const coverageMetrics = data ? getCoverageMetrics(data) : []; const regressions = data ? getBuildIncidents(data) : []; @@ -556,19 +571,8 @@ export const MetricsPage = (): JSX.Element => { return (
-
- { - navigate({ - to: '.', - search: previousSearch => ({ - ...previousSearch, - intervalInDays: days, - }), - }); - }} - /> +
+
diff --git a/dashboard/src/routes/_main/metrics/route.tsx b/dashboard/src/routes/_main/metrics/route.tsx index e1a32e955..e99bb82b6 100644 --- a/dashboard/src/routes/_main/metrics/route.tsx +++ b/dashboard/src/routes/_main/metrics/route.tsx @@ -1,20 +1,3 @@ -import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod'; - -import { makeZIntervalInDays, type SearchSchema } from '@/types/general'; - -const DEFAULT_METRICS_INTERVAL = 7; - -const defaultValues = { - intervalInDays: DEFAULT_METRICS_INTERVAL, -}; - -const metricsSearchSchema = z.object({ - intervalInDays: makeZIntervalInDays(DEFAULT_METRICS_INTERVAL), -} satisfies SearchSchema); - -export const Route = createFileRoute('/_main/metrics')({ - validateSearch: metricsSearchSchema, - search: { middlewares: [stripSearchParams(defaultValues)] }, -}); +export const Route = createFileRoute('/_main/metrics')({});