Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import LfxChip from '~/components/uikit/chip/chip.vue';

const props = defineProps<{
score: number;
healthLabel?: string | null;
unavailable?: boolean;
}>();

Expand All @@ -39,7 +40,13 @@ const props = defineProps<{
// labeled metrics chip). Duplicated rather than shared: only two call sites (row/card) and the
// logic is ~10 lines, so a composable would be more ceremony than the duplication it avoids.
// health-score.vue itself is intentionally left untouched (used elsewhere in the app).
//
// Prefers the API's real healthLabel (v2) when present; falls back to deriving the label from
// the score for sparse rows where the pipe didn't return a label.
const healthScoreLabel = computed(() => {
if (props.healthLabel) {
return props.healthLabel.charAt(0).toUpperCase() + props.healthLabel.slice(1);
}
const score = props.score;
if (score >= 80) return 'Excellent';
if (score >= 60) return 'Healthy';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ SPDX-License-Identifier: MIT
trigger-event="hover"
:allow-pass-through="true"
>
<lfx-collection-health-score-pill :score="project.healthScore" />
<lfx-collection-health-score-pill
:score="project.healthScoreV2 ?? 0"
:health-label="project.healthLabel"
/>
<template #content>
<lfx-health-score-details :project="props.project" />
</template>
Expand Down Expand Up @@ -161,7 +164,8 @@ SPDX-License-Identifier: MIT
<div class="flex items-center gap-1.5 mt-1 text-xs text-neutral-500 flex-wrap">
<template v-if="isOnboarded">
<lfx-collection-health-score-pill
:score="project.healthScore"
:score="project.healthScoreV2 ?? 0"
:health-label="project.healthLabel"
:unavailable="isHealthScoreUnavailable"
/>
<span class="text-neutral-400">・</span>
Expand Down Expand Up @@ -242,12 +246,7 @@ const isOnboarded = computed(() => {
return props.project.contributorCount > 0 || props.project.organizationCount > 0;
});

const isHealthScoreUnavailable = computed(() => {
const { contributorHealthScore, popularityHealthScore, developmentHealthScore, securityHealthScore } = props.project;
return [contributorHealthScore, popularityHealthScore, developmentHealthScore, securityHealthScore].some(
(score) => !score,
);
});
const isHealthScoreUnavailable = computed(() => props.project.healthScoreV2 == null);

const navigateToItem = () => {
if (props.project.type === 'repo') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,113 +5,59 @@ SPDX-License-Identifier: MIT
<template>
<div class="bg-white border border-neutral-100 rounded-xl shadow-xl overflow-hidden px-2 py-3 w-80">
<div class="px-2">
<p class="text-xs leading-4 font-semibold text-neutral-500">Health Score breakdown</p>
<p class="text-xs leading-4 font-semibold text-neutral-500">Health Score</p>
</div>
<div class="flex flex-col gap-1 mt-3">
<nuxt-link
v-for="item in healthScoreItems"
:key="item.key"
:to="{ name: item.route, params: { slug: props.project.slug } }"
class="group flex flex-col gap-2 p-2 rounded-md hover:bg-neutral-50 transition-colors"
<div class="flex flex-col gap-2 mt-3 px-2 pb-1">
<div class="flex items-center justify-between">
<span class="text-xs leading-5 text-neutral-900">
<span class="font-semibold">Overall</span>
<span class="font-normal">・{{ healthLabelDisplay }}</span>
</span>
<span class="text-xs font-semibold text-neutral-900">{{ props.project.healthScoreV2 ?? '-' }}/100</span>
</div>
<lfx-progress-bar
size="small"
:values="[props.project.healthScoreV2 ?? 0]"
:color="getColor(props.project.healthScoreV2)"
/>
<div
v-if="props.project.lifecycleLabel"
class="flex items-center justify-between mt-1"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<lfx-icon
:name="item.icon"
:size="15"
class="text-neutral-900"
/>
<span class="text-xs leading-5 text-neutral-900">
<span class="font-semibold">{{ item.label }}</span>
<span class="font-normal">・{{ getHealthLabel(item.score) }}</span>
</span>
</div>
<lfx-icon
name="arrow-up-right"
:size="12"
class="text-neutral-500 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
<lfx-progress-bar
size="small"
:values="[item.score]"
:color="getColor(item.score)"
/>
</nuxt-link>
<span class="text-xs leading-5 text-neutral-900 font-semibold">Lifecycle</span>
<span class="text-xs leading-5 text-neutral-500 capitalize">{{ props.project.lifecycleLabel }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import LfxIcon from '~/components/uikit/icon/icon.vue';
import LfxProgressBar from '~/components/uikit/progress-bar/progress-bar.vue';
import type { ProgressBarType } from '~/components/uikit/progress-bar/types/progress-bar.types';
import type { ProjectInsights } from '~~/types/project';
import { LfxRoutes } from '~/components/shared/types/routes';

const props = defineProps<{
project: ProjectInsights;
}>();

interface HealthScoreItem {
key: string;
label: string;
icon: string;
score: number;
route: LfxRoutes;
}
const healthLabelDisplay = computed(() => {
const label = props.project.healthLabel;
if (!label) return 'Unavailable';
return label.charAt(0).toUpperCase() + label.slice(1);
});

const healthScoreItems = computed<HealthScoreItem[]>(() => [
{
key: 'contributors',
label: 'Contributors',
icon: 'people-group',
score: props.project.contributorHealthScore ?? 0,
route: LfxRoutes.PROJECT_CONTRIBUTORS,
},
{
key: 'popularity',
label: 'Popularity',
icon: 'fire',
score: props.project.popularityHealthScore ?? 0,
route: LfxRoutes.PROJECT_POPULARITY,
},
{
key: 'development',
label: 'Development',
icon: 'code',
score: props.project.developmentHealthScore ?? 0,
route: LfxRoutes.PROJECT_DEVELOPMENT,
},
{
key: 'security',
label: 'Security & Best practices',
icon: 'shield-check',
score: props.project.securityHealthScore ?? 0,
route: LfxRoutes.PROJECT_SECURITY,
},
]);

const getColor = (value: number): ProgressBarType => {
const getColor = (score: number | null): ProgressBarType => {
if (!score) return 'negative';
switch (true) {
case value >= 75:
case score >= 75:
return 'positive';
case value >= 25:
case score >= 25:
return 'warning';
default:
return 'negative';
}
};

const getHealthLabel = (score: number): string => {
if (!score) return 'Unavailable';
if (score >= 80) return 'Excellent';
if (score >= 60) return 'Healthy';
if (score >= 40) return 'Stable';
if (score >= 20) return 'Unsteady';
return 'Critical';
};
</script>

<script lang="ts">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<!--
Copyright (c) 2025 The Linux Foundation and each contributor.
SPDX-License-Identifier: MIT
-->
<template>
<div class="px-6">
<div class="flex flex-row justify-between">
<div class="pr-6 w-full">
<h2 class="text-heading-3 font-bold font-secondary mb-2">Health score</h2>

<lfx-skeleton-state
v-if="status !== 'pending' && !isEmpty"
Comment thread
Copilot marked this conversation as resolved.
Outdated
:status="status"
height="1.75rem"
width="11.5rem"
>
<div class="flex items-center gap-3 text-3xl font-bold cursor-default">
<div
class="h-3 w-3 rounded-full shrink-0"
:class="scoreColorClass"
/>
{{ scoreLabel }}
<span
v-if="healthScoreV2 !== null"
class="text-neutral-500 text-lg font-semibold"
>{{ healthScoreV2 }}/100</span
>
</div>
</lfx-skeleton-state>

<div
v-if="isEmpty && status !== 'pending'"
class="text-xs text-neutral-500 mt-4"
>
LFX Insights does not have enough meaningful data to generate an overall Health score for this project.
</div>
<p
v-else
class="text-xs text-neutral-500 mt-4"
>
The Insights Health Score measures an open source project's overall trustworthiness.
<a
:href="links.trustScore"
target="_blank"
rel="noopener noreferrer"
class="text-brand-500"
>Learn more</a
>
</p>
</div>
<div
v-if="!isEmpty && status === 'success'"
class="w-[200px] hidden sm:block"
>
<lfx-project-trust-score-share-badge />
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { AsyncDataRequestStatus } from 'nuxt/app';
import LfxProjectTrustScoreShareBadge from './trust-score/share-badge.vue';
import { links } from '~/config/links';
import { getHealthScoreV2Config } from '~~/config/trust-score';
import LfxSkeletonState from '~/components/modules/project/components/shared/skeleton-state.vue';

const props = defineProps<{
healthScoreV2: number | null;
healthLabel: string | null;
status: AsyncDataRequestStatus;
}>();

const isEmpty = computed(() => props.healthScoreV2 === null);

const scoreLabel = computed(() => getHealthScoreV2Config(props.healthLabel).label);

const scoreColorClass = computed(() => {
const label = props.healthLabel;
if (label === 'excellent' || label === 'healthy') return 'bg-positive-500';
if (label === 'fair') return 'bg-brand-500';
if (label === 'concerning') return 'bg-warning-500';
return 'bg-negative-500';
});
</script>
<script lang="ts">
export default {
name: 'LfxProjectTrustScoreV2',
};
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { WidgetArea } from '../../widget/types/widget-area';
import type { Widget } from '../../widget/types/widget';
import type { WidgetConfig } from '../../widget/config/widget.config';
import { lfxWidgets } from '../../widget/config/widget.config';
import type { HealthScoreResults } from '~~/types/overview/responses.types';
import type { HealthScoreResults, HealthScoreV2Results } from '~~/types/overview/responses.types';
import { TanstackKey } from '~/components/shared/types/tanstack';
import type { Organization } from '~~/types/contributors/responses.types';

Expand Down Expand Up @@ -54,6 +54,17 @@ class OverviewApiService {
});
}

fetchHealthScoreV2(params: ComputedRef<{ projectSlug: string }>) {
const queryKey = computed(() => [TanstackKey.HEALTH_SCORE_V2, params.value.projectSlug]);
const queryFn: QueryFunction<HealthScoreV2Results> = async () =>
await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-v2`);

return useQuery<HealthScoreV2Results>({
queryKey,
queryFn,
});
}

fetchAssociatedOrganization(params: ComputedRef<OverviewQueryParams>) {
const queryKey = computed(() => [
TanstackKey.ASSOCIATED_ORGANIZATION,
Expand Down
Loading
Loading