diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index 9eef5758807fb..999d8c14bb2a1 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -37,6 +37,7 @@ "chart.js": "^4.4.9", "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-annotation": "^3.1.0", + "countup.js": "^2.9.0", "dayjs": "^1.11.13", "debounce-promise": "^3.1.2", "elkjs": "^0.10.0", diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index a46bf6e3a1149..c74cf0b1006dd 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.4.9) + countup.js: + specifier: ^2.9.0 + version: 2.9.0 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -2162,6 +2165,9 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + countup.js@2.9.0: + resolution: {integrity: sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -7300,6 +7306,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + countup.js@2.9.0: {} + crelt@1.0.6: {} cross-fetch@3.2.0: diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx index e3f0a01ec875f..10a363ccc8ab1 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx @@ -28,7 +28,8 @@ import { Box, } from "@chakra-ui/react"; import type { ColumnDef } from "@tanstack/react-table"; -import { useCallback, useMemo } from "react"; +import { CountUp } from "countup.js"; +import { useCallback, useMemo, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Link as RouterLink, useSearchParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; @@ -191,11 +192,13 @@ const cardDef: CardDef = { }; const DAGS_LIST_DISPLAY = "dags_list_display"; +const TOTAL_DAGS_COUNT = "total_dags_count"; export const DagsList = () => { - const { t: translate } = useTranslation(); + const { i18n, t: translate } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const [display, setDisplay] = useLocalStorage<"card" | "table">(DAGS_LIST_DISPLAY, "card"); + const [cachedTotalDags, setCachedTotalDags] = useLocalStorage(TOTAL_DAGS_COUNT, 0); const dagRunsLimit = display === "card" ? 14 : 1; const hidePausedDagsByDefault = Boolean(useConfig("hide_paused_dags_by_default")); @@ -272,6 +275,35 @@ export const DagsList = () => { tagsMatchMode: selectedMatchMode, }); + const countUpRef = useRef(null); + const countUpAnimRef = useRef(null); + + useEffect(() => { + if (!countUpRef.current || data?.total_entries === undefined) { + return; + } + + const dgSeparator = new Intl.NumberFormat(i18n.language) + .formatToParts(11_111.0) + .find((part) => part.type === "group")?.value; + + const lastCachedTotalDags = cachedTotalDags; + + setCachedTotalDags(data.total_entries); + + const duration = countUpAnimRef.current ? 0 : 0.5; + + countUpAnimRef.current = new CountUp(countUpRef.current, data.total_entries, { + duration, + separator: dgSeparator, + startVal: lastCachedTotalDags, + }); + + if (!countUpAnimRef.current.error) { + countUpAnimRef.current.start(); + } + }, [data?.total_entries, i18n.language, cachedTotalDags, setCachedTotalDags]); + const handleSortChange = useCallback( ({ value }: SelectValueChangeDetails>) => { setTableURLState({ @@ -298,7 +330,8 @@ export const DagsList = () => { - {`${data?.total_entries ?? 0} ${translate("dag", { count: data?.total_entries ?? 0 })}`} + {new Intl.NumberFormat(i18n.language).format(cachedTotalDags)}{" "} + {translate("dag", { count: cachedTotalDags })}