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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
--
|
||||
|
||||
74
web/src/components/ActivityHeatmap/ActivityHeatmap.css
Normal file
74
web/src/components/ActivityHeatmap/ActivityHeatmap.css
Normal 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;
|
||||
}
|
||||
}
|
||||
256
web/src/components/ActivityHeatmap/ActivityHeatmap.tsx
Normal file
256
web/src/components/ActivityHeatmap/ActivityHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user