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 + + + )}