Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/apps/owasp/graphql/nodes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,25 @@ class ProjectNode(GenericEntityNode):
"""Project node."""

@strawberry.field
def health_metrics(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
def health_metrics_list(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
"""Resolve project health metrics."""
return ProjectHealthMetrics.objects.filter(
project=self,
).order_by(
"nest_created_at",
)[:limit]

@strawberry.field
def health_metrics_latest(self) -> ProjectHealthMetricsNode | None:
"""Resolve latest project health metrics."""
return (
ProjectHealthMetrics.get_latest_health_metrics()
.filter(
project=self,
)
.first()
)

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
12 changes: 12 additions & 0 deletions backend/apps/owasp/graphql/nodes/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
],
Expand All @@ -35,6 +37,11 @@ def age_days(self) -> int:
"""Resolve project age in days."""
return self.age_days

@strawberry.field
def age_days_requirement(self) -> int:
"""Resolve project age requirement in days."""
return self.age_days_requirement

@strawberry.field
def created_at(self) -> datetime:
"""Resolve metrics creation date."""
Expand Down Expand Up @@ -74,3 +81,8 @@ def project_name(self) -> str:
def owasp_page_last_update_days(self) -> int:
"""Resolve OWASP page last update age in days."""
return self.owasp_page_last_update_days

@strawberry.field
def owasp_page_last_update_days_requirement(self) -> int:
"""Resolve OWASP page last update age requirement in days."""
return self.owasp_page_last_update_days_requirement
10 changes: 10 additions & 0 deletions backend/apps/owasp/models/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def age_days(self) -> int:
"""Calculate project age in days."""
return (timezone.now() - self.created_at).days if self.created_at else 0

@property
def age_days_requirement(self) -> int:
"""Get the age requirement for the project."""
return self.project_requirements.age_days

@property
def last_commit_days(self) -> int:
"""Calculate days since last commit."""
Expand Down Expand Up @@ -125,6 +130,11 @@ def owasp_page_last_update_days(self) -> int:
else 0
)

@property
def owasp_page_last_update_days_requirement(self) -> int:
"""Get the OWASP page last update requirement for the project."""
return self.project_requirements.owasp_page_last_update_days

@property
def project_requirements(self) -> ProjectHealthRequirements:
"""Get the project health requirements for the project's level."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def test_meta_configuration(self):
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
}
Expand Down Expand Up @@ -67,8 +69,10 @@ def _get_field_by_name(self, name):
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("project_name", str),
("stars_count", int),
("recent_releases_count", int),
("stars_count", int),
("total_issues_count", int),
("total_releases_count", int),
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def test_resolve_project_health_metrics(self):
is_funding_requirements_compliant=True,
is_leader_requirements_compliant=True,
recent_releases_count=3,
total_issues_count=15,
total_releases_count=5,
)
]
query = ProjectHealthMetricsQuery(project_health_metrics=metrics)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/projects/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const ProjectDetailsPage = () => {
<DetailsCard
details={projectDetails}
entityKey={project.key}
healthMetricsData={project.healthMetrics}
healthMetricsData={project.healthMetricsList}
isActive={project.isActive}
languages={project.languages}
pullRequests={project.recentPullRequests}
Expand Down
149 changes: 149 additions & 0 deletions frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
'use client'

import { useQuery } from '@apollo/client'
import {
faStar,
faFolderOpen,
faQuestionCircle,
faHandshake,
} from '@fortawesome/free-regular-svg-icons'
import {
faPeopleGroup,
faCodeFork,
faDollar,
faCodePullRequest,
faChartArea,
faExclamationCircle,
faTag,
faRocket,
faTags,
} from '@fortawesome/free-solid-svg-icons'
import { useParams } from 'next/navigation'
import { FC, useState, useEffect } from 'react'
import { handleAppError } from 'app/global-error'
import { GET_PROJECT_HEALTH_METRICS_DETAILS } from 'server/queries/projectsHealthDashboardQueries'
import { HealthMetricsProps } from 'types/healthMetrics'
import BarChart from 'components/BarChart'
import DashboardCard from 'components/DashboardCard'
import GeneralCompliantComponent from 'components/GeneralCompliantComponent'
import LoadingSpinner from 'components/LoadingSpinner'
import MetricsScoreCircle from 'components/MetricsScoreCircle'

const ProjectHealthMetricsDetails: FC = () => {
const { projectKey } = useParams()
const [metrics, setMetrics] = useState<HealthMetricsProps>()
const {
loading,
error: graphqlError,
data,
} = useQuery(GET_PROJECT_HEALTH_METRICS_DETAILS, {
variables: { projectKey },
})

useEffect(() => {
if (graphqlError) {
handleAppError(graphqlError)
}
if (data?.project?.healthMetricsLatest) {
setMetrics(data.project.healthMetricsLatest)
}
}, [graphqlError, data])
if (loading) {
return <LoadingSpinner />
}

return (
<div className="flex flex-col gap-4">
{metrics && (
<>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{metrics.projectName}</h1>
<MetricsScoreCircle score={metrics.score} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<GeneralCompliantComponent
icon={faDollar}
compliant={metrics.isFundingRequirementsCompliant}
/>
<GeneralCompliantComponent
icon={faHandshake}
compliant={metrics.isLeaderRequirementsCompliant}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<DashboardCard title="Stars" icon={faStar} stats={metrics.starsCount.toString()} />
<DashboardCard title="Forks" icon={faCodeFork} stats={metrics.forksCount.toString()} />
<DashboardCard
title="Contributors"
icon={faPeopleGroup}
stats={metrics.contributorsCount.toString()}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
<DashboardCard
title="Open Issues"
icon={faExclamationCircle}
stats={metrics.openIssuesCount.toString()}
/>
<DashboardCard
title="Total Issues"
icon={faFolderOpen}
stats={metrics.totalIssuesCount.toString()}
/>
<DashboardCard
title="Unassigned Issues"
icon={faTag}
stats={metrics.unassignedIssuesCount.toString()}
/>
<DashboardCard
title="Unanswered Issues"
icon={faQuestionCircle}
stats={metrics.unansweredIssuesCount.toString()}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<DashboardCard
title="Open Pull Requests"
icon={faCodePullRequest}
stats={metrics.openPullRequestsCount.toString()}
/>
<DashboardCard
title="Recent Releases"
icon={faRocket}
stats={metrics.recentReleasesCount.toString()}
/>
<DashboardCard
title="Total Releases"
icon={faTags}
stats={metrics.totalReleasesCount.toString()}
/>
</div>
<BarChart
title="Days Metrics"
icon={faChartArea}
labels={[
'Age Days',
'Last Commit Days',
'Last Release Days',
'OWASP Page Last Update Days',
]}
days={[
metrics.ageDays,
metrics.lastCommitDays,
metrics.lastReleaseDays,
metrics.owaspPageLastUpdateDays,
]}
requirements={[
metrics.ageDaysRequirement,
metrics.lastCommitDaysRequirement,
metrics.lastReleaseDaysRequirement,
metrics.owaspPageLastUpdateDaysRequirement,
]}
/>
</>
)}
</div>
)
}

export default ProjectHealthMetricsDetails
30 changes: 2 additions & 28 deletions frontend/src/components/CardDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ChapterMapWrapper from 'components/ChapterMapWrapper'
import HealthMetrics from 'components/HealthMetrics'
import InfoBlock from 'components/InfoBlock'
import LeadersList from 'components/LeadersList'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
import Milestones from 'components/Milestones'
import RecentIssues from 'components/RecentIssues'
import RecentPullRequests from 'components/RecentPullRequests'
Expand Down Expand Up @@ -54,15 +55,6 @@ const DetailsCard = ({
type,
userSummary,
}: DetailsCardProps) => {
let scoreStyle = 'bg-green-400 text-green-900'
if (type === 'project' && healthMetricsData.length > 0) {
const score = healthMetricsData[0].score
if (score < 50) {
scoreStyle = 'bg-red-400 text-red-900'
} else if (score < 75) {
scoreStyle = 'bg-yellow-400 text-yellow-900'
}
}
return (
<div className="min-h-screen bg-white p-8 text-gray-600 dark:bg-[#212529] dark:text-gray-300">
<div className="mx-auto max-w-6xl">
Expand All @@ -71,25 +63,7 @@ const DetailsCard = ({
<h1 className="text-4xl font-bold">{title}</h1>
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
<Link href="#issues-trend">
<div
className={`group relative flex h-20 w-20 flex-col items-center justify-center rounded-full border-2 shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl ${scoreStyle}`}
>
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
<div className="relative z-10 flex flex-col items-center text-center">
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">
Health
</span>
<span className="text-xl font-black leading-none">
{healthMetricsData[0].score}
</span>
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">
Score
</span>
</div>
{healthMetricsData[0].score < 30 && (
<div className="animate-pulse absolute inset-0 rounded-full bg-red-400/20"></div>
)}
</div>
<MetricsScoreCircle score={healthMetricsData[0].score} />
</Link>
)}
</div>
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/DashboardCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import SecondaryCard from 'components/SecondaryCard'
const DashboardCard: React.FC<{
readonly title: string
readonly icon: IconProp
readonly stats: string
}> = ({ title, icon, stats }) => {
readonly stats?: string
readonly className?: string
}> = ({ title, icon, stats, className }) => {
return (
<SecondaryCard
title={<AnchorTitle title={title} />}
className="overflow-hidden transition-colors duration-300 hover:bg-blue-100 dark:hover:bg-blue-950"
className={`overflow-hidden transition-colors duration-300 hover:bg-blue-100 dark:hover:bg-blue-950 ${className}`}
>
<span className="flex items-center gap-2 text-2xl font-light">
<FontAwesomeIcon icon={icon} />
<p>{stats}</p>
{stats && <p>{stats}</p>}
</span>
</SecondaryCard>
)
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/GeneralCompliantComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { IconProp } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import clsx from 'clsx'
import { FC } from 'react'
import SecondaryCard from 'components/SecondaryCard'

const GeneralCompliantComponent: FC<{
readonly icon: IconProp
readonly compliant: boolean
}> = ({ icon, compliant }) => {
const greenClass = 'bg-green-100 dark:bg-green-700 hover:bg-green-200 dark:hover:bg-green-600'
const redClass = 'bg-red-100 dark:bg-red-700 hover:bg-red-600 dark:hover:bg-red-600'

return (
<SecondaryCard
className={clsx('pointer-events-auto items-center justify-center text-center font-light', {
[greenClass]: compliant,
[redClass]: !compliant,
})}
>
<FontAwesomeIcon icon={icon} className="text-4xl" />
</SecondaryCard>
)
}

export default GeneralCompliantComponent
26 changes: 26 additions & 0 deletions frontend/src/components/MetricsScoreCircle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { FC } from 'react'

const MetricsScoreCircle: FC<{ score: number }> = ({ score }) => {
let scoreStyle = 'bg-green-400 text-green-900'
if (score < 50) {
scoreStyle = 'bg-red-400 text-red-900'
} else if (score < 75) {
scoreStyle = 'bg-yellow-400 text-yellow-900'
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Extract score thresholds as constants.

The magic numbers for score thresholds should be extracted as constants for better maintainability and consistency with the backend thresholds.

+const SCORE_THRESHOLD_HEALTHY = 75
+const SCORE_THRESHOLD_NEED_ATTENTION = 50
+
 const MetricsScoreCircle: FC<{ score: number }> = ({ score }) => {
   let scoreStyle = 'bg-green-400 text-green-900'
-  if (score < 50) {
+  if (score < SCORE_THRESHOLD_NEED_ATTENTION) {
     scoreStyle = 'bg-red-400 text-red-900'
-  } else if (score < 75) {
+  } else if (score < SCORE_THRESHOLD_HEALTHY) {
     scoreStyle = 'bg-yellow-400 text-yellow-900'
   }
🤖 Prompt for AI Agents
In frontend/src/components/MetricsScoreCircle.tsx around lines 3 to 9, the score
thresholds 50 and 75 are hardcoded magic numbers. Extract these threshold values
into clearly named constants at the top of the file to improve maintainability
and ensure consistency with backend threshold definitions. Replace the hardcoded
numbers in the conditional logic with these constants.

return (
<div
className={`group relative flex h-20 w-20 flex-col items-center justify-center rounded-full border-2 shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl ${scoreStyle}`}
>
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
<div className="relative z-10 flex flex-col items-center text-center">
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">Health</span>
<span className="text-xl font-black leading-none">{score}</span>
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">Score</span>
</div>
{score < 30 && (
<div className="animate-pulse absolute inset-0 rounded-full bg-red-400/20"></div>
)}
</div>
)
}
export default MetricsScoreCircle
Loading
Loading