Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -3,11 +3,17 @@ Copyright (c) 2025 The Linux Foundation and each contributor.
SPDX-License-Identifier: MIT
-->
<template>
<div>
<lfx-empty-state
v-if="props.selectedReposAllArchivedOrExcluded"
icon="archive"
:title="healthScoreFilterEmptyState.stateUnavailable.title"
:description="healthScoreFilterEmptyState.stateUnavailable.description"
/>
<div v-else>
<div class="flex flex-col-reverse sm:flex-row items-start sm:items-center gap-2 sm:gap-4 pb-2">
<h2 class="text-xl leading-8 font-primary font-semibold text-neutral-900">Health breakdown</h2>
<lfx-chip
v-if="props.healthScoreV2 !== null"
v-if="props.healthScoreV2 !== null && !props.isMultipleReposSelected"
type="bordered"
size="xsmall"
class="flex items-center gap-1.5"
Expand All @@ -26,15 +32,18 @@ SPDX-License-Identifier: MIT
</p>

<div
v-if="isEmpty"
class="flex items-center gap-1.5 text-xs text-neutral-500 mb-4"
v-if="isLowSignalCoverage"
class="flex items-start gap-2 p-3 mb-4 bg-accent-100 border border-neutral-100 rounded-md"
>
<lfx-icon
name="circle-info"
:size="14"
class="text-neutral-400 shrink-0"
class="text-accent-500 shrink-0"
/>
<span>Health Score unavailable. All three categories have less than 40% signal coverage for this project.</span>
<div class="flex flex-col gap-1.5 text-xs">
<p class="font-semibold text-neutral-900">Health Score unavailable</p>
<p class="text-accent-900">All three categories have less than 40% signal coverage for this project.</p>
</div>
</div>

<div class="flex flex-col sm:flex-row sm:items-stretch gap-1 p-1 bg-neutral-50 rounded-lg mb-4">
Expand Down Expand Up @@ -100,8 +109,9 @@ import LfxButton from '~/components/uikit/button/button.vue';
import LfxIcon from '~/components/uikit/icon/icon.vue';
import LfxChip from '~/components/uikit/chip/chip.vue';
import LfxBenchmarkIcon from '~/components/uikit/benchmarks/benchmark-icon.vue';
import LfxEmptyState from '~/components/shared/components/empty-state.vue';
import { LfxRoutes } from '~/components/shared/types/routes';
import { getHealthScoreV2Config } from '~~/config/trust-score';
import { getHealthScoreV2Config, healthScoreFilterEmptyState } from '~~/config/trust-score';
import {
getCategoryDescription,
getCategoryScoreColor,
Expand All @@ -126,9 +136,25 @@ const props = defineProps<{
securitySupplyChainScoreV2: number | null;
developmentActivityScoreV2: number | null;
signals: HealthBreakdownResults | null;
selectedReposAllArchivedOrExcluded: boolean;
isRepoSelected: boolean;
isMultipleReposSelected: boolean;
}>();

const isEmpty = computed(() => props.healthScoreV2 === null);
const allCategoriesEmpty = computed(
() =>
props.maintainerHealthScoreV2 === null &&
props.securitySupplyChainScoreV2 === null &&
props.developmentActivityScoreV2 === null,
);

// healthScoreV2 is intentionally null when a repo filter is active (State 2) — the
// low-signal-coverage banner only applies to the unfiltered, project-wide total (State 1),
// and only when the category scores are also missing (a total masked for other reasons
// while categories still have real data would make this message factually wrong).
const isLowSignalCoverage = computed(
() => props.healthScoreV2 === null && !props.isRepoSelected && allCategoriesEmpty.value,
);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ Copyright (c) 2025 The Linux Foundation and each contributor.
SPDX-License-Identifier: MIT
-->
<template>
<div class="px-6">
<template v-if="!isArchived">
<div
class="px-6"
:class="{ 'pb-6': !showShareBadge }"
>
<template v-if="!isEntireProjectArchived">
<lfx-skeleton-state
:status="status"
height="10rem"
Expand All @@ -13,43 +16,65 @@ SPDX-License-Identifier: MIT
<!-- TEMPORARILY HIDDEN (IN-1243): grid-cols-2 instead of grid-cols-3 while Impact is hidden, so Health Score/Lifecycle split evenly. Revert to md:grid-cols-3 when Impact is re-enabled. -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col items-start gap-3">
<div class="flex flex-row flex-wrap items-start justify-between gap-3 w-full">
<div class="flex flex-col items-start gap-2">
<span class="text-xs font-semibold text-neutral-500 tracking-wide flex items-center gap-1">
HEALTH SCORE
<lfx-tooltip placement="top">
<lfx-icon
name="circle-question"
:size="11"
class="cursor-help text-neutral-400"
/>
<template #content>
<div class="max-w-xs text-xs leading-relaxed">
The Insights Health Score measures an open source project's overall trustworthiness, based on
maintainer activity, security posture, and development cadence.
</div>
</template>
</lfx-tooltip>
</span>
<span
class="text-lg font-semibold"
:class="isEmpty ? 'text-neutral-400' : scoreTextColorClass"
>{{ scoreLabel }}</span
>
<template v-if="!isRepoSelected">
<div class="flex flex-row flex-wrap items-start justify-between gap-3 w-full">
<div class="flex flex-col items-start gap-2">
<span class="text-xs font-semibold text-neutral-500 tracking-wide flex items-center gap-1">
HEALTH SCORE
<lfx-tooltip placement="top">
<lfx-icon
name="circle-question"
:size="11"
class="cursor-help text-neutral-400"
/>
<template #content>
<div class="max-w-xs text-xs leading-relaxed">
The Insights Health Score measures an open source project's overall trustworthiness, based on
maintainer activity, security posture, and development cadence.
</div>
</template>
</lfx-tooltip>
</span>
<span
class="text-lg font-semibold"
:class="isEmpty ? 'text-neutral-400' : scoreTextColorClass"
>{{ scoreLabel }}</span
>
</div>
<lfx-health-score-ring
:score="healthScoreV2 ?? 0"
:color="scoreColorHex"
:unavailable="isEmpty"
/>
</div>
<lfx-health-score-ring
:score="healthScoreV2 ?? 0"
:color="scoreColorHex"
:unavailable="isEmpty"
<div class="flex-grow" />
<p
v-if="healthScoreDescription"
class="text-xs text-neutral-500"
>
{{ healthScoreDescription }}
</p>
</template>

<div
v-else-if="!selectedReposAllArchivedOrExcluded"
class="text-xs text-brand-600 font-semibold inline-flex items-center gap-1 bg-brand-50 rounded-full px-1.5 py-1"
>
<lfx-icon
name="info-circle"
:size="12"
type="solid"
class="text-brand-600"
/>
{{ healthScoreFilterEmptyState.stateSelectAll.description }}
</div>
<div class="flex-grow" />
<p
v-if="healthScoreDescription"
class="text-xs text-neutral-500"
>
{{ healthScoreDescription }}
</p>

<lfx-empty-state
v-else
icon="archive"
:title="healthScoreFilterEmptyState.stateUnavailable.title"
:description="healthScoreFilterEmptyState.stateUnavailable.description"
/>
</div>

<!-- TEMPORARILY HIDDEN (IN-1243): Impact section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
Expand Down Expand Up @@ -150,6 +175,7 @@ import {
// TEMPORARILY HIDDEN (IN-1243): Impact section disabled until underlying data quality issue is fixed. Re-enable by uncommenting.
// getImpactLabelDisplay,
getLifecycleLabelConfig,
healthScoreFilterEmptyState,
} from '~~/config/trust-score';
import { lfxColors } from '~/config/styles/colors';
import LfxSkeletonState from '~/components/modules/project/components/shared/skeleton-state.vue';
Expand Down Expand Up @@ -181,12 +207,22 @@ const props = defineProps<{
signals: HealthBreakdownResults | null;
}>();

const { isArchived, emptyStateTitle, emptyStateDescription, selectedRepositories } = storeToRefs(useProjectStore());
const {
isEntireProjectArchived,
emptyStateTitle,
emptyStateDescription,
selectedRepositories,
selectedReposAllArchivedOrExcluded,
} = storeToRefs(useProjectStore());

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

// When a repo is selected, the badge switches to an active-contributors badge (see
// share-badge.vue) which doesn't depend on the Health Score total, so it isn't gated on
// `isEmpty` in that case - only the project-wide Health Score badge needs a non-null total.
const showShareBadge = computed(
() => !isEmpty.value && props.status === 'success' && selectedRepositories.value.length <= 1,
() =>
props.status === 'success' && selectedRepositories.value.length <= 1 && (props.isRepoSelected || !isEmpty.value),
);

const scoreLabel = computed(() => getHealthScoreV2Config(props.healthLabel).label);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ const props = defineProps<{
link: ProjectLinkConfig;
}>();

const emit = defineEmits<{
(e: 'close'): void;
}>();

const route = useRoute();
const router = useRouter();

Expand Down Expand Up @@ -149,6 +153,7 @@ const handleReposChange = (repo: RepositoryItem) => {
params: { name: repos[0] },
query: { ...routeQuery, repos: undefined },
});
emit('close');
} else {
router.push({
name: props.link.projectRouteName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ export interface ScoreDataQueryParams extends OverviewQueryParams {

// TODO: Refactor other services to follow this pattern
class OverviewApiService {
fetchHealthScoreV2(params: ComputedRef<{ projectSlug: string }>) {
const queryKey = computed(() => [TanstackKey.HEALTH_SCORE_V2, params.value.projectSlug]);
fetchHealthScoreV2(params: ComputedRef<OverviewQueryParams>) {
const queryKey = computed(() => [
TanstackKey.HEALTH_SCORE_V2,
params.value.projectSlug,
params.value.repos,
]);
const queryFn: QueryFunction<HealthScoreV2Results> = async () =>
await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-v2`);
await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-v2`, {
params: { repos: params.value.repos },
});

return useQuery<HealthScoreV2Results>({
queryKey,
Expand All @@ -52,10 +58,16 @@ class OverviewApiService {
});
}

fetchHealthScoreBreakdown(params: ComputedRef<{ projectSlug: string }>) {
const queryKey = computed(() => [TanstackKey.HEALTH_SCORE_BREAKDOWN, params.value.projectSlug]);
fetchHealthScoreBreakdown(params: ComputedRef<OverviewQueryParams>) {
const queryKey = computed(() => [
TanstackKey.HEALTH_SCORE_BREAKDOWN,
params.value.projectSlug,
params.value.repos,
]);
const queryFn: QueryFunction<HealthBreakdownResults> = async () =>
await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-breakdown`);
await $fetch(`/api/project/${params.value.projectSlug}/overview/health-score-breakdown`, {
params: { repos: params.value.repos },
});

return useQuery<HealthBreakdownResults>({
queryKey,
Expand Down
Loading
Loading