Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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,7 +3,13 @@ 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
Expand All @@ -26,7 +32,7 @@ SPDX-License-Identifier: MIT
</p>

<div
v-if="isEmpty"
v-if="isLowSignalCoverage"
class="flex items-center gap-1.5 text-xs text-neutral-500 mb-4"
>
<lfx-icon
Expand Down Expand Up @@ -100,8 +106,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 +133,13 @@ const props = defineProps<{
securitySupplyChainScoreV2: number | null;
developmentActivityScoreV2: number | null;
signals: HealthBreakdownResults | null;
selectedReposAllArchivedOrExcluded: boolean;
isRepoSelected: boolean;
}>();

const isEmpty = computed(() => props.healthScoreV2 === 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).
const isLowSignalCoverage = computed(() => props.healthScoreV2 === null && !props.isRepoSelected);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SPDX-License-Identifier: MIT
-->
<template>
<div class="px-6">
<template v-if="!isArchived">
<template v-if="!isEntireProjectArchived">
<lfx-skeleton-state
:status="status"
height="10rem"
Expand All @@ -13,43 +13,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 +172,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 +204,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
48 changes: 38 additions & 10 deletions frontend/app/components/modules/project/store/project.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { useRoute } from 'nuxt/app';
import { useRouter } from 'nuxt/app';
import { DateTime } from 'luxon';
import pluralize from 'pluralize';
import {
Expand Down Expand Up @@ -48,15 +48,20 @@ export const defaultDateOption = lfxProjectDateOptions.find(
);

export const useProjectStore = defineStore('project', () => {
const route = useRoute();
// `useRoute()` inside a Pinia store setup can resolve to a snapshot that stops
// tracking client-side navigations (observed: store's route stayed pinned to the
// route active when the store was first instantiated). `router.currentRoute` is the
// literal ref vue-router mutates on every navigation, so reading it here guarantees
// this store always sees the current route.
const route = useRouter().currentRoute;

// Collection detail pages (/collection/details/[slug]/...) reuse this store for the
// date-range/granularity state widgets share, but have no single project's repos,
// repo groups, or widgets array to filter by - there's no per-project concept at all
// when scope is a whole collection. Every repo/archived-repo-dependent computed below
// short-circuits to a safe empty/false value in that case instead of trying to derive
// "the collection's repos" from a null project.
const isCollectionScope = computed(() => route.path.startsWith('/collection/details/'));
const isCollectionScope = computed(() => route.value.path.startsWith('/collection/details/'));

const { queryParams } = useQueryParam(processProjectParams, projectParamsSetter);
const { timeRange, start, end } = queryParams.value;
Expand Down Expand Up @@ -97,22 +102,23 @@ export const useProjectStore = defineStore('project', () => {
// List of excluded repositories
const excludedRepos = computed<string[]>(() => project.value?.excludedRepositories || []);

// Selected repositories from URL param 'repos' or single repo from route param 'name'
const selectedRepoSlugs = computed(() => {
// Selected repositories from URL param 'repos' or single repo from route param 'name'.
const selectedRepoSlugs = computed<string[]>(() => {
if (isCollectionScope.value) {
return [];
}
return route.params.name
? [route.params.name as string]
: (route.query.repos as string)?.split(',') || [];
return route.value.params.name
? [route.value.params.name as string]
: (route.value.query.repos as string)?.split(',') || [];
});

// Selected repository Group
const selectedRepositoryGroup = computed<ProjectRepositoryGroup | null>(() => {
if (isCollectionScope.value) {
return null;
}
const groupSlug = (route.params.groupSlug as string | undefined) || route.query.repositoryGroup;
const groupSlug =
(route.value.params.groupSlug as string | undefined) || route.value.query.repositoryGroup;
if (!groupSlug || !projectRepositoryGroups.value.length) {
return null;
}
Expand All @@ -131,7 +137,7 @@ export const useProjectStore = defineStore('project', () => {
}
return projectRepos.value.filter(
(repo: ProjectRepository) =>
selectedRepoSlugs.value.includes(repo.slug) || route.params.name === repo.slug,
selectedRepoSlugs.value.includes(repo.slug) || route.value.params.name === repo.slug,
);
});

Expand Down Expand Up @@ -162,6 +168,16 @@ export const useProjectStore = defineStore('project', () => {
// If all repos are archived or the project is archived
const isArchived = computed(() => allArchived.value || isProjectArchived.value);

// Unlike `isArchived`, this excludes the "current selection happens to be all archived"
// case - it's true only for genuine whole-project archival, not a selection-driven one.
const isEntireProjectArchived = computed(
() =>
isProjectArchived.value ||
(!isCollectionScope.value &&
!!projectRepos.value.length &&
archivedRepos.value.length === projectRepos.value.length),
);

const emptyStateTitle = computed(() => {
if (isProjectArchived.value) {
return 'Archived Project';
Expand All @@ -182,6 +198,16 @@ export const useProjectStore = defineStore('project', () => {
selectedReposValues.value.some((repo) => archivedRepos.value.includes(repo)),
);

// True only when there IS an explicit repo selection and every selected repo is archived
// or excluded. Separate from `allArchived`, which only accounts for `archivedRepos`.
const selectedReposAllArchivedOrExcluded = computed(
() =>
!!selectedReposValues.value.length &&
selectedReposValues.value.every(
(repo) => archivedRepos.value.includes(repo) || excludedRepos.value.includes(repo),
),
);

return {
isCollectionScope,
selectedTimeRangeKey,
Expand All @@ -200,9 +226,11 @@ export const useProjectStore = defineStore('project', () => {
selectedReposValues,
allArchived,
hasSelectedArchivedRepos,
selectedReposAllArchivedOrExcluded,
collaborationSet,
isProjectArchived,
isArchived,
isEntireProjectArchived,
emptyStateTitle,
emptyStateDescription,
};
Expand Down
Loading
Loading