From 57a5e3368fbcbe4f2f9c3d74ffa4e7d689619d03 Mon Sep 17 00:00:00 2001 From: jbpratt Date: Thu, 30 Oct 2025 10:27:47 -0500 Subject: [PATCH] feat(ui): add repository activity heatmap (PROJQUAY-9353) (#4398) * feat(ui): add repository activity heatmap (PROJQUAY-9353) Implements an activity heatmap showing last 90 days of repository pull/push activity to match feature available in Angular UI. Features: - Continuous week-by-week calendar grid layout - Smart month labels (Aug, Sep, Oct) with spacing optimization - Day labels (Mon, Wed, Fri) positioned clearly - 5-level color scaling from gray (no activity) to dark blue (high activity) - Interactive tooltips with date and action counts - Full-width responsive design - PatternFly design system integration - ARIA labels for accessibility Technical implementation: - Custom React component using SVG rendering - API integration with includeStats=true endpoint - Color-coded cells based on activity intensity - Tooltip with high contrast for dark/light mode support Co-authored-by: Claude Signed-off-by: Brady Pratt * fix(test): resolve builds.cy.ts failures from includeStats param change (PROJQUAY-9353) Fixed 21 failing Cypress tests in builds.cy.ts caused by two issues: 1. Updated all repository detail API intercepts to use includeStats=true instead of includeStats=false to match the actual API call changed in the heatmap feature implementation 2. Added optional chaining to error.response?.status in ErrorHandling.ts to prevent null reference errors when error.response is undefined All 27 tests now pass (previously 6 passing, 21 failing). Co-authored-by: Claude Signed-off-by: Brady Pratt --------- Signed-off-by: Brady Pratt Co-authored-by: Claude --- web/cypress/e2e/builds.cy.ts | 12 +- web/cypress/e2e/repository-details.cy.ts | 10 + web/cypress/test/quay-db-data.txt | 8 +- .../ActivityHeatmap/ActivityHeatmap.css | 74 +++++ .../ActivityHeatmap/ActivityHeatmap.tsx | 256 ++++++++++++++++++ web/src/resources/ErrorHandling.ts | 2 +- web/src/resources/RepositoryResource.ts | 8 +- .../Information/Information.tsx | 19 +- 8 files changed, 371 insertions(+), 18 deletions(-) create mode 100644 web/src/components/ActivityHeatmap/ActivityHeatmap.css create mode 100644 web/src/components/ActivityHeatmap/ActivityHeatmap.tsx diff --git a/web/cypress/e2e/builds.cy.ts b/web/cypress/e2e/builds.cy.ts index f0c888fe9..6ee506291 100644 --- a/web/cypress/e2e/builds.cy.ts +++ b/web/cypress/e2e/builds.cy.ts @@ -250,7 +250,7 @@ describe('Repository Builds', () => { ); cy.intercept( 'GET', - '/api/v1/repository/testorg/testrepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/testrepo?includeStats=true&includeTags=false', {fixture: 'testrepo.json'}, ).as('getRepo'); cy.intercept('GET', '/api/v1/organization/testorg', { @@ -263,7 +263,7 @@ describe('Repository Builds', () => { repoFixture.state = 'MIRROR'; cy.intercept( 'GET', - '/api/v1/repository/testorg/testrepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/testrepo?includeStats=true&includeTags=false', repoFixture, ).as('getRepo'); }); @@ -285,7 +285,7 @@ describe('Repository Builds', () => { repoFixture.state = 'READONLY'; cy.intercept( 'GET', - '/api/v1/repository/testorg/testrepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/testrepo?includeStats=true&includeTags=false', repoFixture, ).as('getRepo'); }); @@ -981,7 +981,7 @@ describe('Repository Builds', () => { }).as('getBuildTriggers'); cy.intercept( 'GET', - '/api/v1/repository/testorg/privaterepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/privaterepo?includeStats=true&includeTags=false', {statusCode: 200, body: {is_public: false}}, ).as('getRepoDetails'); cy.intercept( @@ -1300,7 +1300,7 @@ describe('Repository Builds - Create GitHub Build Triggers', () => { ); cy.intercept( 'GET', - '/api/v1/repository/testorg/testrepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/testrepo?includeStats=true&includeTags=false', {fixture: 'testrepo.json'}, ).as('getRepo'); cy.intercept('GET', '/api/v1/organization/testorg', { @@ -1774,7 +1774,7 @@ describe('Repository Builds - View build logs', () => { fixture.can_write = true; cy.intercept( 'GET', - '/api/v1/repository/testorg/testrepo?includeStats=false&includeTags=false', + '/api/v1/repository/testorg/testrepo?includeStats=true&includeTags=false', fixture, ).as('getrepo'); }); diff --git a/web/cypress/e2e/repository-details.cy.ts b/web/cypress/e2e/repository-details.cy.ts index 4478028bd..74ec3e256 100644 --- a/web/cypress/e2e/repository-details.cy.ts +++ b/web/cypress/e2e/repository-details.cy.ts @@ -27,6 +27,16 @@ describe('Repository Details Page', () => { cy.contains('Description').should('exist'); }); + it('renders repository activity heatmap', () => { + cy.visit('/repository/user1/hello-world'); + // Verify Repository Activity card exists + cy.contains('Repository Activity').should('exist'); + // Verify heatmap SVG is rendered + cy.get('.activity-heatmap-svg').should('exist'); + // Verify heatmap has cells + cy.get('.activity-heatmap-cell').should('have.length.greaterThan', 0); + }); + it('edits repository description', () => { cy.intercept('PUT', '/api/v1/repository/user1/hello-world', { statusCode: 200, diff --git a/web/cypress/test/quay-db-data.txt b/web/cypress/test/quay-db-data.txt index 7d26fcfbb..987035e6c 100644 --- a/web/cypress/test/quay-db-data.txt +++ b/web/cypress/test/quay-db-data.txt @@ -6317,6 +6317,9 @@ COPY public.logentry3 (id, kind_id, account_id, performer_id, repository_id, dat 203 97 1 \N \N 2023-11-07 19:54:19.160078 172.18.0.1 {"type": "quayauth", "useragent": "Mozilla/5.0"} 204 76 6 1 \N 2024-11-26 13:28:52.179631 172.20.0.1 {"upstream_registry": "docker.io"} 205 46 1 1 1 2025-08-14 17:34:11.334478 10.89.0.5 {"username": "user1", "repo": "hello-world", "tag": "latest", "manifest_digest": "sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4"} +206 42 1 1 1 2025-10-10 16:30:12.348000 172.18.0.1 {"repo": "hello-world", "namespace": "user1", "user-agent": "cosign/v2.0.0", "tag": "sha256-f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4.sig", "username": "user1", "resolved_ip": {"provider": "internet", "service": null, "sync_token": "1645662201", "country_iso_code": null, "aws_region": null}} +207 42 1 1 1 2025-10-10 16:30:34.121000 172.18.0.1 {"repo": "hello-world", "namespace": "user1", "user-agent": "cosign/v2.0.0", "tag": "sha256-f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4.sbom", "username": "user1", "resolved_ip": {"provider": "internet", "service": null, "sync_token": "1645662201", "country_iso_code": null, "aws_region": null}} +208 42 1 1 1 2025-10-10 16:30:51.238000 172.18.0.1 {"repo": "hello-world", "namespace": "user1", "user-agent": "cosign/v2.0.0", "tag": "sha256-f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4.att", "username": "user1", "resolved_ip": {"provider": "internet", "service": null, "sync_token": "1645662201", "country_iso_code": null, "aws_region": null}} \. @@ -7139,6 +7142,7 @@ COPY public.repositoryactioncount (id, repository_id, count, date) FROM stdin; 155 155 0 2023-06-27 156 156 0 2023-08-22 157 157 0 2023-12-18 +158 1 3 2025-10-10 \. @@ -8322,7 +8326,7 @@ SELECT pg_catalog.setval('public.logentry2_id_seq', 1, false); -- Name: logentry3_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.logentry3_id_seq', 205, true); +SELECT pg_catalog.setval('public.logentry3_id_seq', 208, true); -- @@ -8574,7 +8578,7 @@ SELECT pg_catalog.setval('public.repository_id_seq', 157, true); -- Name: repositoryactioncount_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.repositoryactioncount_id_seq', 157, true); +SELECT pg_catalog.setval('public.repositoryactioncount_id_seq', 158, true); -- diff --git a/web/src/components/ActivityHeatmap/ActivityHeatmap.css b/web/src/components/ActivityHeatmap/ActivityHeatmap.css new file mode 100644 index 000000000..951b42506 --- /dev/null +++ b/web/src/components/ActivityHeatmap/ActivityHeatmap.css @@ -0,0 +1,74 @@ +.activity-heatmap { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 0.5rem 0; +} + +.activity-heatmap-svg { + display: block; + width: 100%; + height: auto; +} + +.activity-heatmap-cell { + cursor: pointer; + transition: opacity 0.2s ease; +} + +.activity-heatmap-cell:hover { + opacity: 0.8; + stroke-width: 1.5; +} + +.activity-heatmap-month-label { + font-family: var(--pf-t--global--font--family--body); + font-weight: var(--pf-t--global--font--weight--body--bold); + fill: var(--pf-v5-global--Color--200); + user-select: none; +} + +.activity-heatmap-day-label { + font-family: var(--pf-t--global--font--family--body); + fill: var(--pf-v5-global--Color--200); + user-select: none; +} + +.activity-heatmap-tooltip { + z-index: 1000; + background-color: var(--pf-t--global--color--nonstatus--black--100, #000); + color: var(--pf-t--global--color--nonstatus--white--100, #fff); + padding: 0.5rem 0.75rem; + border-radius: var(--pf-t--global--border--radius--small); + box-shadow: var(--pf-t--global--box-shadow--lg); + border: 1px solid var(--pf-t--global--border--color--default); + font-size: var(--pf-t--global--font--size--body--sm); + white-space: nowrap; +} + +.tooltip-content { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tooltip-date { + font-weight: var(--pf-t--global--font--weight--body--bold); +} + +.tooltip-count { + font-size: var(--pf-t--global--font--size--body--xs); + opacity: 0.9; +} + +/* Responsive adjustments for mobile */ +@media (max-width: 768px) { + .activity-heatmap { + overflow-x: scroll; + } + + .activity-heatmap-svg { + width: 100%; + min-width: 500px; + } +} diff --git a/web/src/components/ActivityHeatmap/ActivityHeatmap.tsx b/web/src/components/ActivityHeatmap/ActivityHeatmap.tsx new file mode 100644 index 000000000..1d2841256 --- /dev/null +++ b/web/src/components/ActivityHeatmap/ActivityHeatmap.tsx @@ -0,0 +1,256 @@ +import {useState, useMemo} from 'react'; +import './ActivityHeatmap.css'; + +export interface ActivityData { + date: string; // ISO date string + count: number; +} + +interface ActivityHeatmapProps { + data: ActivityData[]; + itemName?: string; +} + +interface DayCell { + date: Date; + count: number; + weekIndex: number; + dayOfWeek: number; +} + +export default function ActivityHeatmap(props: ActivityHeatmapProps) { + const {data = [], itemName = 'action'} = props; + const [hoveredCell, setHoveredCell] = useState(null); + const [mousePosition, setMousePosition] = useState({x: 0, y: 0}); + + const cellSize = 10; + const cellMargin = 2; + const labelOffset = 30; + + // Process data into a map for quick lookup + const dataMap = useMemo(() => { + const map = new Map(); + data.forEach((item) => { + const date = new Date(item.date); + const key = date.toISOString().split('T')[0]; // 'YYYY-MM-DD' + map.set(key, item.count); + }); + return map; + }, [data]); + + // Calculate max count for color scaling + const maxCount = useMemo(() => { + return Math.max(...data.map((d) => d.count), 1); + }, [data]); + + // Get color based on count + const getColor = (count: number): string => { + if (count === 0) return 'var(--pf-t--global--color--grey--50, #f4f4f4)'; + + const intensity = count / maxCount; + if (intensity <= 0.25) + return 'var(--pf-t--global--color--blue--200, #c9e9fb)'; + if (intensity <= 0.5) + return 'var(--pf-t--global--color--blue--300, #7ec9e8)'; + if (intensity <= 0.75) + return 'var(--pf-t--global--color--blue--400, #4ba9d6)'; + return 'var(--pf-t--global--color--blue--500, #4682b4)'; + }; + + // Generate continuous calendar data for the last 3 months + const {calendarData, monthLabels, numWeeks} = useMemo(() => { + const cells: DayCell[] = []; + const monthLabels: {month: string; weekIndex: number}[] = []; + const today = new Date(); + + // Start from 90 days ago + const startDate = new Date(today); + startDate.setDate(startDate.getDate() - 89); + + // Find the Sunday before the start date to align weeks + const dayOfWeek = startDate.getDay(); + const alignedStartDate = new Date(startDate); + alignedStartDate.setDate(alignedStartDate.getDate() - dayOfWeek); + + // Calculate number of weeks needed + const endDate = new Date(today); + const daysSinceStart = Math.ceil( + (endDate.getTime() - alignedStartDate.getTime()) / (1000 * 60 * 60 * 24), + ); + const weeks = Math.ceil(daysSinceStart / 7); + + let currentMonth = -1; + + // Generate cells for each day in the range + for (let week = 0; week < weeks; week++) { + for (let day = 0; day < 7; day++) { + const currentDate = new Date(alignedStartDate); + currentDate.setDate(alignedStartDate.getDate() + week * 7 + day); + + // Only include cells within our actual date range + if (currentDate >= startDate && currentDate <= endDate) { + const key = currentDate.toISOString().split('T')[0]; // 'YYYY-MM-DD' + const count = dataMap.get(key) || 0; + + // Track month changes for labels + if (currentDate.getMonth() !== currentMonth && day === 0) { + currentMonth = currentDate.getMonth(); + monthLabels.push({ + month: currentDate.toLocaleDateString('en-US', {month: 'short'}), + weekIndex: week, + }); + } + + cells.push({ + date: currentDate, + count, + weekIndex: week, + dayOfWeek: day, + }); + } + } + } + + return {calendarData: cells, monthLabels, numWeeks: weeks}; + }, [dataMap]); + + const handleMouseEnter = (cell: DayCell, event: React.MouseEvent) => { + setHoveredCell(cell); + setMousePosition({ + x: event.clientX, + y: event.clientY, + }); + }; + + const handleMouseLeave = () => { + setHoveredCell(null); + }; + + const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + // Calculate total dimensions + const totalWidth = numWeeks * (cellSize + cellMargin) + labelOffset; + const totalHeight = 7 * (cellSize + cellMargin) + 20; + + return ( +
+ + {/* Month labels */} + {(() => { + // Filter month labels to avoid crowding - keep months that are at least 3 weeks apart + const filteredLabels = []; + let lastShownIndex = -1; + + for (let i = 0; i < monthLabels.length; i++) { + if (lastShownIndex === -1) { + // First label - check if we should skip it for the next one + if ( + i + 1 < monthLabels.length && + monthLabels[i + 1].weekIndex - monthLabels[i].weekIndex < 3 + ) { + // Skip this one, prefer the next month which likely has more weeks + continue; + } + filteredLabels.push(monthLabels[i]); + lastShownIndex = i; + } else if ( + monthLabels[i].weekIndex - + monthLabels[lastShownIndex].weekIndex >= + 3 + ) { + filteredLabels.push(monthLabels[i]); + lastShownIndex = i; + } + } + + return filteredLabels.map((label, index) => ( + + {label.month} + + )); + })()} + + {/* Day of week labels */} + {['Mon', 'Wed', 'Fri'].map((day, index) => ( + + {day} + + ))} + + {/* Heatmap cells */} + + {calendarData.map((cell, index) => { + const x = cell.weekIndex * (cellSize + cellMargin); + const y = cell.dayOfWeek * (cellSize + cellMargin); + + return ( + handleMouseEnter(cell, e)} + onMouseLeave={handleMouseLeave} + role="img" + aria-label={`${formatDate(cell.date)}: ${ + cell.count + } ${itemName}${cell.count !== 1 ? 's' : ''}`} + /> + ); + })} + + + + {/* Tooltip */} + {hoveredCell && ( +
+
+
{formatDate(hoveredCell.date)}
+
+ {hoveredCell.count} {itemName} + {hoveredCell.count !== 1 ? 's' : ''} +
+
+
+ )} +
+ ); +} diff --git a/web/src/resources/ErrorHandling.ts b/web/src/resources/ErrorHandling.ts index 3ab6484ab..9ef4e54c7 100644 --- a/web/src/resources/ErrorHandling.ts +++ b/web/src/resources/ErrorHandling.ts @@ -79,7 +79,7 @@ export function getErrorMessage(error: AxiosError) { return getNetworkError(error.code as AxiosErrorCode); } - if (error.response.status) { + if (error.response?.status) { // For server errors (5xx), provide user-friendly message if (error.response.status >= 500 && error.response.status < 600) { return 'an unexpected issue occurred. Please try again or contact support'; diff --git a/web/src/resources/RepositoryResource.ts b/web/src/resources/RepositoryResource.ts index e14d7964f..d50f8d177 100644 --- a/web/src/resources/RepositoryResource.ts +++ b/web/src/resources/RepositoryResource.ts @@ -144,6 +144,11 @@ export async function fetchRepositories() { return response.data?.repositories as IRepository[]; } +export interface RepositoryStats { + date: string; + count: number; +} + export interface RepositoryDetails { can_admin: boolean; can_write: boolean; @@ -159,11 +164,12 @@ export interface RepositoryDetails { status_token: string | null; tag_expiration_s: number | null; trust_enabled: boolean; + stats?: RepositoryStats[]; } export async function fetchRepositoryDetails(org: string, repo: string) { const response: AxiosResponse = await axios.get( - `/api/v1/repository/${org}/${repo}?includeStats=false&includeTags=false`, + `/api/v1/repository/${org}/${repo}?includeStats=true&includeTags=false`, ); assertHttpCode(response.status, 200); return response.data; diff --git a/web/src/routes/RepositoryDetails/Information/Information.tsx b/web/src/routes/RepositoryDetails/Information/Information.tsx index 2d31d3363..544949386 100644 --- a/web/src/routes/RepositoryDetails/Information/Information.tsx +++ b/web/src/routes/RepositoryDetails/Information/Information.tsx @@ -12,7 +12,6 @@ import { GridItem, PageSection, PageSectionVariants, - Skeleton, Text, TextArea, TextContent, @@ -30,6 +29,7 @@ import {AlertVariant} from 'src/atoms/AlertState'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import React from 'react'; +import ActivityHeatmap from 'src/components/ActivityHeatmap/ActivityHeatmap'; import './Information.css'; interface InformationProps { @@ -155,17 +155,20 @@ export default function Information(props: InformationProps) { return ( - {/* Repository Activity Placeholder */} + {/* Repository Activity Heatmap */} Repository Activity - - - - Activity heatmap coming soon - - + {repoDetails?.stats && repoDetails.stats.length > 0 ? ( + + ) : ( + + + No activity data available + + + )}