1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

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 <noreply@anthropic.com>
Signed-off-by: Brady Pratt <bpratt@redhat.com>

* 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 <noreply@anthropic.com>
Signed-off-by: Brady Pratt <bpratt@redhat.com>

---------

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jbpratt
2025-10-30 10:27:47 -05:00
committed by GitHub
parent 6ebf38e9fa
commit 57a5e3368f
8 changed files with 371 additions and 18 deletions

View File

@@ -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');
});

View File

@@ -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,

View File

@@ -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);
--

View File

@@ -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;
}
}

View File

@@ -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<DayCell | null>(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<string, number>();
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 (
<div className="activity-heatmap">
<svg
className="activity-heatmap-svg"
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
preserveAspectRatio="none"
>
{/* 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) => (
<text
key={index}
x={labelOffset + label.weekIndex * (cellSize + cellMargin)}
y={12}
className="activity-heatmap-month-label"
fontSize="7"
>
{label.month}
</text>
));
})()}
{/* Day of week labels */}
{['Mon', 'Wed', 'Fri'].map((day, index) => (
<text
key={day}
x={20}
y={20 + (index * 2 + 1) * (cellSize + cellMargin) + cellSize / 2}
className="activity-heatmap-day-label"
fontSize="7"
textAnchor="end"
dominantBaseline="middle"
>
{day}
</text>
))}
{/* Heatmap cells */}
<g transform={`translate(${labelOffset}, 20)`}>
{calendarData.map((cell, index) => {
const x = cell.weekIndex * (cellSize + cellMargin);
const y = cell.dayOfWeek * (cellSize + cellMargin);
return (
<rect
key={index}
x={x}
y={y}
width={cellSize}
height={cellSize}
fill={getColor(cell.count)}
stroke="var(--pf-t--global--color--grey--200)"
strokeWidth="0.5"
rx="2"
className="activity-heatmap-cell"
onMouseEnter={(e) => handleMouseEnter(cell, e)}
onMouseLeave={handleMouseLeave}
role="img"
aria-label={`${formatDate(cell.date)}: ${
cell.count
} ${itemName}${cell.count !== 1 ? 's' : ''}`}
/>
);
})}
</g>
</svg>
{/* Tooltip */}
{hoveredCell && (
<div
className="activity-heatmap-tooltip"
style={{
position: 'fixed',
left: mousePosition.x + 10,
top: mousePosition.y + 10,
pointerEvents: 'none',
}}
>
<div className="tooltip-content">
<div className="tooltip-date">{formatDate(hoveredCell.date)}</div>
<div className="tooltip-count">
{hoveredCell.count} {itemName}
{hoveredCell.count !== 1 ? 's' : ''}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -79,7 +79,7 @@ export function getErrorMessage(error: AxiosError<ErrorResponse>) {
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';

View File

@@ -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<RepositoryDetails> = 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;

View File

@@ -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 (
<PageSection variant={PageSectionVariants.light}>
<Grid hasGutter>
{/* Repository Activity Placeholder */}
{/* Repository Activity Heatmap */}
<GridItem span={12} md={5}>
<Card>
<CardTitle>Repository Activity</CardTitle>
<CardBody>
<Skeleton height="200px" />
<TextContent style={{marginTop: '1rem', textAlign: 'center'}}>
<Text component={TextVariants.small}>
Activity heatmap coming soon
</Text>
</TextContent>
{repoDetails?.stats && repoDetails.stats.length > 0 ? (
<ActivityHeatmap data={repoDetails.stats} itemName="action" />
) : (
<TextContent style={{textAlign: 'center', padding: '2rem'}}>
<Text component={TextVariants.small}>
No activity data available
</Text>
</TextContent>
)}
</CardBody>
</Card>
</GridItem>