@nospt/plugin-dev-ai-hub 1.1.3 → 1.2.0-rc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -2
- package/config.schema.json +15 -0
- package/dist/api/DevAiHubResourceClient.esm.js +42 -2
- package/dist/api/DevAiHubResourceClient.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js +98 -20
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js +2 -2
- package/dist/components/AiResourcesPage/ResourceCard.esm.js +13 -4
- package/dist/components/AiResourcesPage/ResourceCard.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js +2 -2
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js +55 -9
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js +57 -3
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js +4 -2
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/SortControl.esm.js +139 -0
- package/dist/components/AiResourcesPage/SortControl.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/SortControl.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/SortControl.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ownerLabel.esm.js +6 -0
- package/dist/components/AiResourcesPage/ownerLabel.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/typeMeta.esm.js +1 -12
- package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -1
- package/dist/components/Backoffice/Backoffice.module.css.esm.js +8 -0
- package/dist/components/Backoffice/Backoffice.module.css.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeCuration.esm.js +191 -0
- package/dist/components/Backoffice/BackofficeCuration.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeExclusion.esm.js +185 -0
- package/dist/components/Backoffice/BackofficeExclusion.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeGate.esm.js +25 -0
- package/dist/components/Backoffice/BackofficeGate.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeHeaderAction.esm.js +43 -0
- package/dist/components/Backoffice/BackofficeHeaderAction.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeOverview.esm.js +517 -0
- package/dist/components/Backoffice/BackofficeOverview.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeSection.esm.js +37 -0
- package/dist/components/Backoffice/BackofficeSection.esm.js.map +1 -0
- package/dist/components/Backoffice/TypeDistributionBar.esm.js +44 -0
- package/dist/components/Backoffice/TypeDistributionBar.esm.js.map +1 -0
- package/dist/components/Backoffice/rowEntrance.esm.js +11 -0
- package/dist/components/Backoffice/rowEntrance.esm.js.map +1 -0
- package/dist/components/Backoffice/useCurationAccess.esm.js +12 -0
- package/dist/components/Backoffice/useCurationAccess.esm.js.map +1 -0
- package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js +25 -0
- package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js.map +1 -0
- package/dist/components/RecommendedBadge/recommended-badge.svg +9 -0
- package/dist/components/ToolIcon/ToolIcon.esm.js +41 -6
- package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
- package/dist/hooks/useDefaultSortMode.esm.js +37 -0
- package/dist/hooks/useDefaultSortMode.esm.js.map +1 -0
- package/dist/hooks/useFrameworkLabel.esm.js +42 -0
- package/dist/hooks/useFrameworkLabel.esm.js.map +1 -0
- package/dist/hooks/useResources.esm.js +25 -14
- package/dist/hooks/useResources.esm.js.map +1 -1
- package/dist/hooks/useTelemetryCounts.esm.js +16 -7
- package/dist/hooks/useTelemetryCounts.esm.js.map +1 -1
- package/dist/hooks/useTelemetrySummary.esm.js +46 -0
- package/dist/hooks/useTelemetrySummary.esm.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.esm.js +1 -1
- package/dist/plugin.esm.js +90 -3
- package/dist/plugin.esm.js.map +1 -1
- package/dist/routes.esm.js +2 -1
- package/dist/routes.esm.js.map +1 -1
- package/dist/theme/typeTokens.module.css.esm.js +5 -0
- package/dist/theme/typeTokens.module.css.esm.js.map +1 -0
- package/package.json +10 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BackofficeSection.esm.js","sources":["../../../src/components/Backoffice/BackofficeSection.tsx"],"sourcesContent":["import type { ReactNode } from 'react';\nimport { Skeleton, Text } from '@backstage/ui';\nimport styles from './Backoffice.module.css';\n\nexport interface BackofficeSectionProps {\n title: string;\n description?: string;\n /** Data is still loading; renders placeholder rows instead of children. */\n loading?: boolean;\n /** Loading failed; renders the message instead of children. */\n error?: Error;\n /** Loaded successfully but there is nothing to show. */\n isEmpty?: boolean;\n emptyMessage?: string;\n children?: ReactNode;\n}\n\n/**\n * The shared frame every backoffice section renders inside (#83).\n *\n * It exists so the three states a section can be in — loading, failed, empty —\n * are decided once, here, rather than re-invented per section. A new\n * administrative section supplies a title and its content and inherits all\n * three; that is what \"sections can be added through the surface\" buys.\n */\nexport function BackofficeSection(props: BackofficeSectionProps) {\n const {\n title,\n description,\n loading,\n error,\n isEmpty,\n emptyMessage = 'Nothing to show yet.',\n children,\n } = props;\n\n return (\n <section className={styles.section}>\n <header className={styles.sectionHeader}>\n <Text variant=\"title-small\" weight=\"bold\" as=\"h2\">\n {title}\n </Text>\n {description && (\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {description}\n </Text>\n )}\n </header>\n\n {loading && (\n <div className={styles.grid} data-testid=\"backoffice-section-loading\">\n {Array.from({ length: 4 }).map((_, i) => (\n <Skeleton\n key={i}\n style={{ height: 88, borderRadius: 'var(--bui-radius-2)' }}\n />\n ))}\n </div>\n )}\n\n {!loading && error && (\n <div className={styles.stateBlock} role=\"alert\">\n <Text variant=\"body-medium\" weight=\"bold\" as=\"p\">\n Could not load this section\n </Text>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {error.message}\n </Text>\n </div>\n )}\n\n {!loading && !error && isEmpty && (\n <div className={styles.stateBlock}>\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {emptyMessage}\n </Text>\n </div>\n )}\n\n {!loading && !error && !isEmpty && children}\n </section>\n );\n}\n"],"names":[],"mappings":";;;;AAyBO,SAAS,kBAAkB,KAAA,EAA+B;AAC/D,EAAA,MAAM;AAAA,IACJ,KAAA;AAAA,IACA,WAAA;AAAA,IACA,OAAA;AAAA,IACA,KAAA;AAAA,IACA,OAAA;AAAA,IACA,YAAA,GAAe,sBAAA;AAAA,IACf;AAAA,GACF,GAAI,KAAA;AAEJ,EAAA,uBACE,IAAA,CAAC,SAAA,EAAA,EAAQ,SAAA,EAAW,MAAA,CAAO,OAAA,EACzB,QAAA,EAAA;AAAA,oBAAA,IAAA,CAAC,QAAA,EAAA,EAAO,SAAA,EAAW,MAAA,CAAO,aAAA,EACxB,QAAA,EAAA;AAAA,sBAAA,GAAA,CAAC,QAAK,OAAA,EAAQ,aAAA,EAAc,QAAO,MAAA,EAAO,EAAA,EAAG,MAC1C,QAAA,EAAA,KAAA,EACH,CAAA;AAAA,MACC,WAAA,wBACE,IAAA,EAAA,EAAK,OAAA,EAAQ,cAAa,KAAA,EAAM,WAAA,EAAY,EAAA,EAAG,GAAA,EAC7C,QAAA,EAAA,WAAA,EACH;AAAA,KAAA,EAEJ,CAAA;AAAA,IAEC,2BACC,GAAA,CAAC,KAAA,EAAA,EAAI,WAAW,MAAA,CAAO,IAAA,EAAM,eAAY,4BAAA,EACtC,QAAA,EAAA,KAAA,CAAM,IAAA,CAAK,EAAE,QAAQ,CAAA,EAAG,EAAE,GAAA,CAAI,CAAC,GAAG,CAAA,qBACjC,GAAA;AAAA,MAAC,QAAA;AAAA,MAAA;AAAA,QAEC,KAAA,EAAO,EAAE,MAAA,EAAQ,EAAA,EAAI,cAAc,qBAAA;AAAsB,OAAA;AAAA,MADpD;AAAA,KAGR,CAAA,EACH,CAAA;AAAA,IAGD,CAAC,WAAW,KAAA,oBACX,IAAA,CAAC,SAAI,SAAA,EAAW,MAAA,CAAO,UAAA,EAAY,IAAA,EAAK,OAAA,EACtC,QAAA,EAAA;AAAA,sBAAA,GAAA,CAAC,QAAK,OAAA,EAAQ,aAAA,EAAc,QAAO,MAAA,EAAO,EAAA,EAAG,KAAI,QAAA,EAAA,6BAAA,EAEjD,CAAA;AAAA,sBACA,GAAA,CAAC,QAAK,OAAA,EAAQ,YAAA,EAAa,OAAM,WAAA,EAAY,EAAA,EAAG,GAAA,EAC7C,QAAA,EAAA,KAAA,CAAM,OAAA,EACT;AAAA,KAAA,EACF,CAAA;AAAA,IAGD,CAAC,OAAA,IAAW,CAAC,SAAS,OAAA,oBACrB,GAAA,CAAC,SAAI,SAAA,EAAW,MAAA,CAAO,YACrB,QAAA,kBAAA,GAAA,CAAC,IAAA,EAAA,EAAK,SAAQ,YAAA,EAAa,KAAA,EAAM,aAAY,EAAA,EAAG,GAAA,EAC7C,wBACH,CAAA,EACF,CAAA;AAAA,IAGD,CAAC,OAAA,IAAW,CAAC,KAAA,IAAS,CAAC,OAAA,IAAW;AAAA,GAAA,EACrC,CAAA;AAEJ;;;;"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { getTypeMeta } from '../AiResourcesPage/typeMeta.esm.js';
|
|
3
|
+
import styles from './Backoffice.module.css.esm.js';
|
|
4
|
+
|
|
5
|
+
function TypeDistributionBar({
|
|
6
|
+
counts,
|
|
7
|
+
types,
|
|
8
|
+
total
|
|
9
|
+
}) {
|
|
10
|
+
if (total === 0) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const segments = types.map((type) => ({ type, meta: getTypeMeta(type), count: counts[type] })).filter((segment) => segment.count > 0);
|
|
14
|
+
const summary = segments.map(
|
|
15
|
+
(segment) => `${segment.meta.pluralLabel} ${Math.round(
|
|
16
|
+
segment.count / total * 100
|
|
17
|
+
)}%`
|
|
18
|
+
).join(", ");
|
|
19
|
+
return /* @__PURE__ */ jsx(
|
|
20
|
+
"div",
|
|
21
|
+
{
|
|
22
|
+
className: styles.distributionBar,
|
|
23
|
+
role: "img",
|
|
24
|
+
"aria-label": `Catalog composition by type: ${summary}`,
|
|
25
|
+
children: segments.map((segment) => /* @__PURE__ */ jsx(
|
|
26
|
+
"span",
|
|
27
|
+
{
|
|
28
|
+
className: styles.distributionSegment,
|
|
29
|
+
style: {
|
|
30
|
+
"--card-accent": segment.meta.color,
|
|
31
|
+
flexGrow: segment.count
|
|
32
|
+
},
|
|
33
|
+
title: `${segment.meta.pluralLabel}: ${Math.round(
|
|
34
|
+
segment.count / total * 100
|
|
35
|
+
)}% (${segment.count})`
|
|
36
|
+
},
|
|
37
|
+
segment.type
|
|
38
|
+
))
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { TypeDistributionBar };
|
|
44
|
+
//# sourceMappingURL=TypeDistributionBar.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TypeDistributionBar.esm.js","sources":["../../../src/components/Backoffice/TypeDistributionBar.tsx"],"sourcesContent":["import type { ResourceType } from '@nospt/plugin-dev-ai-hub-common';\nimport { getTypeMeta } from '../AiResourcesPage/typeMeta';\nimport styles from './Backoffice.module.css';\n\nexport interface TypeDistributionBarProps {\n /** Resource count per type (#83) — the same map the type grid above\n * renders from, so the bar can never disagree with the numbers next to\n * it. */\n counts: Record<ResourceType, number>;\n types: readonly ResourceType[];\n total: number;\n}\n\n/**\n * A single stacked bar showing each type's share of the catalog (#83) — the\n * \"one glance\" companion to the type grid above, which already gives the\n * exact count and percentage per type. Coloured with the same per-type\n * identity (ADR-0008, `--card-accent`) the grid and the browse page both\n * use, so this reads as one more view of the same data, not a second visual\n * language.\n *\n * Renders nothing for an empty catalog — `BackofficeSection` already swaps\n * in its own empty state before this component's parent renders, so `total`\n * is never really 0 here, but the guard keeps a stray zero-width bar from\n * ever appearing if that assumption changes.\n */\nexport function TypeDistributionBar({\n counts,\n types,\n total,\n}: TypeDistributionBarProps) {\n if (total === 0) {\n return null;\n }\n\n // Types with nothing in them add nothing to the bar — an invisible sliver\n // is just noise, and `flexGrow: 0` still costs a DOM node.\n const segments = types\n .map(type => ({ type, meta: getTypeMeta(type), count: counts[type] }))\n .filter(segment => segment.count > 0);\n\n const summary = segments\n .map(\n segment =>\n `${segment.meta.pluralLabel} ${Math.round(\n (segment.count / total) * 100,\n )}%`,\n )\n .join(', ');\n\n return (\n <div\n className={styles.distributionBar}\n role=\"img\"\n aria-label={`Catalog composition by type: ${summary}`}\n >\n {segments.map(segment => (\n <span\n key={segment.type}\n className={styles.distributionSegment}\n style={\n {\n '--card-accent': segment.meta.color,\n flexGrow: segment.count,\n } as React.CSSProperties\n }\n title={`${segment.meta.pluralLabel}: ${Math.round(\n (segment.count / total) * 100,\n )}% (${segment.count})`}\n />\n ))}\n </div>\n );\n}\n"],"names":[],"mappings":";;;;AA0BO,SAAS,mBAAA,CAAoB;AAAA,EAClC,MAAA;AAAA,EACA,KAAA;AAAA,EACA;AACF,CAAA,EAA6B;AAC3B,EAAA,IAAI,UAAU,CAAA,EAAG;AACf,IAAA,OAAO,IAAA;AAAA,EACT;AAIA,EAAA,MAAM,QAAA,GAAW,MACd,GAAA,CAAI,CAAA,IAAA,MAAS,EAAE,IAAA,EAAM,IAAA,EAAM,YAAY,IAAI,CAAA,EAAG,OAAO,MAAA,CAAO,IAAI,GAAE,CAAE,CAAA,CACpE,OAAO,CAAA,OAAA,KAAW,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAEtC,EAAA,MAAM,UAAU,QAAA,CACb,GAAA;AAAA,IACC,aACE,CAAA,EAAG,OAAA,CAAQ,IAAA,CAAK,WAAW,IAAI,IAAA,CAAK,KAAA;AAAA,MACjC,OAAA,CAAQ,QAAQ,KAAA,GAAS;AAAA,KAC3B,CAAA,CAAA;AAAA,GACL,CACC,KAAK,IAAI,CAAA;AAEZ,EAAA,uBACE,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,WAAW,MAAA,CAAO,eAAA;AAAA,MAClB,IAAA,EAAK,KAAA;AAAA,MACL,YAAA,EAAY,gCAAgC,OAAO,CAAA,CAAA;AAAA,MAElD,QAAA,EAAA,QAAA,CAAS,IAAI,CAAA,OAAA,qBACZ,GAAA;AAAA,QAAC,MAAA;AAAA,QAAA;AAAA,UAEC,WAAW,MAAA,CAAO,mBAAA;AAAA,UAClB,KAAA,EACE;AAAA,YACE,eAAA,EAAiB,QAAQ,IAAA,CAAK,KAAA;AAAA,YAC9B,UAAU,OAAA,CAAQ;AAAA,WACpB;AAAA,UAEF,OAAO,CAAA,EAAG,OAAA,CAAQ,IAAA,CAAK,WAAW,KAAK,IAAA,CAAK,KAAA;AAAA,YACzC,OAAA,CAAQ,QAAQ,KAAA,GAAS;AAAA,WAC3B,CAAA,GAAA,EAAM,OAAA,CAAQ,KAAK,CAAA,CAAA;AAAA,SAAA;AAAA,QAVf,OAAA,CAAQ;AAAA,OAYhB;AAAA;AAAA,GACH;AAEJ;;;;"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import styles from './Backoffice.module.css.esm.js';
|
|
2
|
+
|
|
3
|
+
function rowEntrance(playEntrance, index) {
|
|
4
|
+
return playEntrance ? {
|
|
5
|
+
className: styles.rowEntrance,
|
|
6
|
+
style: { "--row-index": index }
|
|
7
|
+
} : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { rowEntrance };
|
|
11
|
+
//# sourceMappingURL=rowEntrance.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rowEntrance.esm.js","sources":["../../../src/components/Backoffice/rowEntrance.ts"],"sourcesContent":["import styles from './Backoffice.module.css';\n\n/**\n * Row-index-staggered entrance class/style, or nothing once it has played\n * (#83). Shared by every Admin Panel grid/list (`BackofficeOverview`,\n * `BackofficeCuration`) so the \"same love\" polish pass stays one\n * implementation instead of two drifting copies.\n */\nexport function rowEntrance(\n playEntrance: boolean,\n index: number,\n): { className?: string; style?: React.CSSProperties } {\n return playEntrance\n ? {\n className: styles.rowEntrance,\n style: { '--row-index': index } as React.CSSProperties,\n }\n : {};\n}\n"],"names":[],"mappings":";;AAQO,SAAS,WAAA,CACd,cACA,KAAA,EACqD;AACrD,EAAA,OAAO,YAAA,GACH;AAAA,IACE,WAAW,MAAA,CAAO,WAAA;AAAA,IAClB,KAAA,EAAO,EAAE,aAAA,EAAe,KAAA;AAAM,MAEhC,EAAC;AACP;;;;"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { usePermission } from '@backstage/plugin-permission-react';
|
|
2
|
+
import { devAiHubBackofficeCuratePermission } from '@nospt/plugin-dev-ai-hub-common';
|
|
3
|
+
|
|
4
|
+
function useCurationAccess() {
|
|
5
|
+
const { loading, allowed } = usePermission({
|
|
6
|
+
permission: devAiHubBackofficeCuratePermission
|
|
7
|
+
});
|
|
8
|
+
return { loading, allowed };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { useCurationAccess };
|
|
12
|
+
//# sourceMappingURL=useCurationAccess.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useCurationAccess.esm.js","sources":["../../../src/components/Backoffice/useCurationAccess.ts"],"sourcesContent":["import { usePermission } from '@backstage/plugin-permission-react';\nimport { devAiHubBackofficeCuratePermission } from '@nospt/plugin-dev-ai-hub-common';\n\n/**\n * Whether the current user may change a resource's curation state (#68,\n * ADR-0017) — distinct from `useBackofficeAccess`, which only gates *seeing*\n * the backoffice. A user can have one without the other: read-only access to\n * the backoffice is a real, supported state, not a bug.\n */\nexport function useCurationAccess(): { loading: boolean; allowed: boolean } {\n const { loading, allowed } = usePermission({\n permission: devAiHubBackofficeCuratePermission,\n });\n return { loading, allowed };\n}\n"],"names":[],"mappings":";;;AASO,SAAS,iBAAA,GAA4D;AAC1E,EAAA,MAAM,EAAE,OAAA,EAAS,OAAA,EAAQ,GAAI,aAAA,CAAc;AAAA,IACzC,UAAA,EAAY;AAAA,GACb,CAAA;AACD,EAAA,OAAO,EAAE,SAAS,OAAA,EAAQ;AAC5B;;;;"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { useApi, configApiRef } from '@backstage/core-plugin-api';
|
|
3
|
+
import defaultRecommendedBadgeUrl from './recommended-badge.svg';
|
|
4
|
+
|
|
5
|
+
function RecommendedBadgeIcon({
|
|
6
|
+
size = 14,
|
|
7
|
+
className
|
|
8
|
+
}) {
|
|
9
|
+
const config = useApi(configApiRef);
|
|
10
|
+
const src = config.getOptionalString("devAiHub.ui.recommendedBadgeIcon") ?? defaultRecommendedBadgeUrl;
|
|
11
|
+
return /* @__PURE__ */ jsx(
|
|
12
|
+
"img",
|
|
13
|
+
{
|
|
14
|
+
src,
|
|
15
|
+
width: size,
|
|
16
|
+
height: size,
|
|
17
|
+
className,
|
|
18
|
+
alt: "",
|
|
19
|
+
"aria-hidden": "true"
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { RecommendedBadgeIcon };
|
|
25
|
+
//# sourceMappingURL=RecommendedBadgeIcon.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecommendedBadgeIcon.esm.js","sources":["../../../src/components/RecommendedBadge/RecommendedBadgeIcon.tsx"],"sourcesContent":["/// <reference types=\"@backstage/cli/asset-types\" />\nimport { configApiRef, useApi } from '@backstage/core-plugin-api';\nimport defaultRecommendedBadgeUrl from './recommended-badge.svg';\n\nexport interface RecommendedBadgeIconProps {\n size?: number;\n className?: string;\n}\n\n/**\n * The \"Recommended\" mark (#68, ADR-0017): a medal-and-ribbon graphic.\n *\n * Shipped as an actual image asset (`recommended-badge.svg`) rendered through\n * an `<img>`, rather than an inline `<svg>` drawn in the component. A curator's\n * opinion deserves a mark of its own — something a viewer reads as \"the team\n * put a badge on this\" — rather than reusing a generic star glyph that already\n * means other things elsewhere in the UI (e.g. the popularity 🔥 indicator).\n *\n * The bundled asset is the default, but a deployment can swap the mark for its\n * own via `devAiHub.ui.recommendedBadgeIcon` in `app-config` (any\n * browser-loadable source — absolute URL, static path, or data URI). Backstage's\n * bundler resolves the default `*.svg` import to a URL via\n * `@backstage/cli/asset-types`; the mark stays crisp at any `size`.\n *\n * The default's amber (`#92400e`, the BUI warning foreground) and white centre\n * are baked into the asset because both places it renders — the browse card\n * (amber-on-cream) and the curation list (amber-on-white) — use the same amber,\n * so a single image serves both without a per-context tint.\n */\nexport function RecommendedBadgeIcon({\n size = 14,\n className,\n}: RecommendedBadgeIconProps) {\n const config = useApi(configApiRef);\n const src =\n config.getOptionalString('devAiHub.ui.recommendedBadgeIcon') ??\n defaultRecommendedBadgeUrl;\n\n return (\n <img\n src={src}\n width={size}\n height={size}\n className={className}\n alt=\"\"\n aria-hidden=\"true\"\n />\n );\n}\n"],"names":[],"mappings":";;;;AA6BO,SAAS,oBAAA,CAAqB;AAAA,EACnC,IAAA,GAAO,EAAA;AAAA,EACP;AACF,CAAA,EAA8B;AAC5B,EAAA,MAAM,MAAA,GAAS,OAAO,YAAY,CAAA;AAClC,EAAA,MAAM,GAAA,GACJ,MAAA,CAAO,iBAAA,CAAkB,kCAAkC,CAAA,IAC3D,0BAAA;AAEF,EAAA,uBACE,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,KAAA,EAAO,IAAA;AAAA,MACP,MAAA,EAAQ,IAAA;AAAA,MACR,SAAA;AAAA,MACA,GAAA,EAAI,EAAA;AAAA,MACJ,aAAA,EAAY;AAAA;AAAA,GACd;AAEJ;;;;"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" role="img" aria-label="Recommended">
|
|
2
|
+
<!-- Ribbon tails, behind the medal -->
|
|
3
|
+
<path d="M8.6 13.5H11V21L9.8 18.5L8.6 21V13.5Z" fill="#92400E" fill-opacity="0.55" />
|
|
4
|
+
<path d="M13 13.5H15.4V21L14.2 18.5L13 21V13.5Z" fill="#92400E" fill-opacity="0.55" />
|
|
5
|
+
<!-- Medal — a 12-point rosette -->
|
|
6
|
+
<polygon points="12.00,1.80 13.45,3.59 15.60,2.76 15.96,5.04 18.24,5.40 17.41,7.55 19.20,9.00 17.41,10.45 18.24,12.60 15.96,12.96 15.60,15.24 13.45,14.41 12.00,16.20 10.55,14.41 8.40,15.24 8.04,12.96 5.76,12.60 6.59,10.45 4.80,9.00 6.59,7.55 5.76,5.40 8.04,5.04 8.40,2.76 10.55,3.59" fill="#92400E" />
|
|
7
|
+
<!-- Inner star -->
|
|
8
|
+
<polygon points="12.00,5.60 12.82,7.87 15.23,7.95 13.33,9.43 14.00,11.75 12.00,10.40 10.00,11.75 10.67,9.43 8.77,7.95 11.18,7.87" fill="#FFFFFF" />
|
|
9
|
+
</svg>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx } from 'react/jsx-runtime';
|
|
2
|
-
import { RiInfinityLine, RiTerminalBoxLine } from '@remixicon/react';
|
|
2
|
+
import { RiInfinityLine, RiTerminalBoxLine, RiSparkling2Line } from '@remixicon/react';
|
|
3
3
|
import { siGooglegemini, siGithub, siAnthropic } from 'simple-icons';
|
|
4
|
+
import { normalizeFramework } from '@nospt/plugin-dev-ai-hub-common';
|
|
5
|
+
import { useCustomFrameworks } from '../../hooks/useFrameworkLabel.esm.js';
|
|
4
6
|
|
|
5
7
|
const CURSOR_PATH = "m20.42 6.73l-8-4.62a.82.82 0 0 0-.83 0L3.58 6.73c-.22.12-.35.36-.35.61v9.32c0 .25.13.48.35.61l8.01 4.62c.26.15.57.15.83 0l8.01-4.62c.22-.12.35-.36.35-.61V7.34c0-.25-.13-.48-.35-.61Zm-.5.98L12.19 21.1c-.05.09-.19.05-.19-.05v-8.77c0-.18-.09-.34-.25-.43L4.16 7.47c-.09-.05-.05-.19.05-.19h15.46c.22 0 .36.24.25.43";
|
|
6
8
|
const TOOL_ICON = {
|
|
@@ -15,7 +17,9 @@ function ToolIcon({
|
|
|
15
17
|
style,
|
|
16
18
|
size = 20
|
|
17
19
|
}) {
|
|
18
|
-
|
|
20
|
+
const custom = useCustomFrameworks();
|
|
21
|
+
const token = normalizeFramework(tool);
|
|
22
|
+
if (token === "all") {
|
|
19
23
|
return /* @__PURE__ */ jsx(
|
|
20
24
|
RiInfinityLine,
|
|
21
25
|
{
|
|
@@ -27,7 +31,7 @@ function ToolIcon({
|
|
|
27
31
|
}
|
|
28
32
|
);
|
|
29
33
|
}
|
|
30
|
-
if (
|
|
34
|
+
if (token === "opencode") {
|
|
31
35
|
return /* @__PURE__ */ jsx(
|
|
32
36
|
RiTerminalBoxLine,
|
|
33
37
|
{
|
|
@@ -42,7 +46,7 @@ function ToolIcon({
|
|
|
42
46
|
}
|
|
43
47
|
);
|
|
44
48
|
}
|
|
45
|
-
if (
|
|
49
|
+
if (token === "cursor") {
|
|
46
50
|
return /* @__PURE__ */ jsx(
|
|
47
51
|
"svg",
|
|
48
52
|
{
|
|
@@ -62,8 +66,39 @@ function ToolIcon({
|
|
|
62
66
|
}
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
|
-
const cfg = TOOL_ICON[
|
|
66
|
-
if (!cfg)
|
|
69
|
+
const cfg = TOOL_ICON[token];
|
|
70
|
+
if (!cfg) {
|
|
71
|
+
const entry = custom.get(token);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!entry.icon) {
|
|
76
|
+
return /* @__PURE__ */ jsx(
|
|
77
|
+
RiSparkling2Line,
|
|
78
|
+
{
|
|
79
|
+
size,
|
|
80
|
+
className,
|
|
81
|
+
style: {
|
|
82
|
+
color: branded ? "var(--bui-fg-primary)" : "inherit",
|
|
83
|
+
...style
|
|
84
|
+
},
|
|
85
|
+
"aria-label": entry.displayName,
|
|
86
|
+
role: "img"
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return /* @__PURE__ */ jsx(
|
|
91
|
+
"img",
|
|
92
|
+
{
|
|
93
|
+
src: entry.icon,
|
|
94
|
+
width: size,
|
|
95
|
+
height: size,
|
|
96
|
+
className,
|
|
97
|
+
style: { objectFit: "contain", flexShrink: 0, ...style },
|
|
98
|
+
alt: entry.displayName
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
}
|
|
67
102
|
const brandedColor = cfg.monochrome ? "var(--bui-fg-primary)" : `#${cfg.hex}`;
|
|
68
103
|
return /* @__PURE__ */ jsx(
|
|
69
104
|
"svg",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ToolIcon.esm.js","sources":["../../../src/components/ToolIcon/ToolIcon.tsx"],"sourcesContent":["import type { CSSProperties } from 'react';\nimport {
|
|
1
|
+
{"version":3,"file":"ToolIcon.esm.js","sources":["../../../src/components/ToolIcon/ToolIcon.tsx"],"sourcesContent":["import type { CSSProperties } from 'react';\nimport {\n RiInfinityLine,\n RiSparkling2Line,\n RiTerminalBoxLine,\n} from '@remixicon/react';\nimport { siAnthropic, siGithub, siGooglegemini } from 'simple-icons';\nimport {\n normalizeFramework,\n type FrameworkToken,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { useCustomFrameworks } from '../../hooks/useFrameworkLabel';\n\n/* Cursor's cube mark isn't in simple-icons; path from Boxicons Brands\n (`bxl:cursor-ai`, MIT). Monochrome brand — no branded hex. */\nconst CURSOR_PATH =\n 'm20.42 6.73l-8-4.62a.82.82 0 0 0-.83 0L3.58 6.73c-.22.12-.35.36-.35.61v9.32c0 .25.13.48.35.61l8.01 4.62c.26.15.57.15.83 0l8.01-4.62c.22-.12.35-.36.35-.61V7.34c0-.25-.13-.48-.35-.61Zm-.5.98L12.19 21.1c-.05.09-.19.05-.19-.05v-8.77c0-.18-.09-.34-.25-.43L4.16 7.47c-.09-.05-.05-.19.05-.19h15.46c.22 0 .36.24.25.43';\n\ntype SvgTool = Exclude<FrameworkToken, 'all' | 'cursor' | 'opencode'>;\n\n/**\n * Anthropic's and GitHub's marks are monochrome brand assets (near-black,\n * `#191919` / `#181717`) meant to be manually inverted per surface — used\n * literally, they're invisible on the dark theme's dark backgrounds. Treat\n * them like `cursor` below (theme-aware `--bui-fg-primary`) instead of their\n * literal hex. Google's mark is an actual mid-tone brand colour (`#8E75B2`)\n * that reads fine on both themes, so it keeps its literal hex.\n */\nconst TOOL_ICON: Record<\n SvgTool,\n { path: string; hex: string; label: string; monochrome?: boolean }\n> = {\n 'claude-code': { ...siAnthropic, label: 'Claude Code', monochrome: true },\n 'github-copilot': { ...siGithub, label: 'GitHub Copilot', monochrome: true },\n 'google-gemini': { ...siGooglegemini, label: 'Google Gemini' },\n};\n\ninterface ToolIconProps {\n /**\n * A framework token. Deliberately `string` rather than `FrameworkToken`:\n * deployments register their own tokens via `devAiHub.frameworks.custom`\n * (ADR-0018), and ADR-0003 keeps unknown tokens rendering rather than\n * dropping them.\n */\n tool: string;\n /** Use the brand's official color. Defaults to true. */\n branded?: boolean;\n className?: string;\n style?: CSSProperties;\n /** Icon size in pixels. Defaults to 20. */\n size?: number;\n}\n\nexport function ToolIcon({\n tool,\n branded = true,\n className,\n style,\n size = 20,\n}: ToolIconProps) {\n const custom = useCustomFrameworks();\n const token = normalizeFramework(tool);\n\n if (token === 'all') {\n return (\n <RiInfinityLine\n size={size}\n className={className}\n style={{ color: 'var(--bui-fg-secondary)', ...style }}\n aria-label=\"Universal\"\n // Without an explicit role the label is not exposed as an image name,\n // which the other two branches already set. It matters wherever the\n // icon stands alone (resource cards), where it is the only thing\n // naming the tool.\n role=\"img\"\n />\n );\n }\n\n if (token === 'opencode') {\n // No verified brand mark is available to embed (unlike Cursor's, sourced\n // from a known open-license icon set) — a neutral terminal icon avoids\n // guessing at OpenCode's actual logomark geometry.\n return (\n <RiTerminalBoxLine\n size={size}\n className={className}\n style={{\n color: branded ? 'var(--bui-fg-primary)' : 'inherit',\n ...style,\n }}\n aria-label=\"OpenCode\"\n role=\"img\"\n />\n );\n }\n\n if (token === 'cursor') {\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n width={size}\n height={size}\n fill=\"currentColor\"\n className={className}\n style={{\n color: branded ? 'var(--bui-fg-primary)' : 'inherit',\n ...style,\n }}\n aria-label=\"Cursor\"\n role=\"img\"\n >\n <path d={CURSOR_PATH} />\n </svg>\n );\n }\n\n const cfg = TOOL_ICON[token as SvgTool];\n if (!cfg) {\n // A deployment-registered framework (ADR-0018). Its icon is an arbitrary\n // browser-loadable source, so it renders through an `<img>` and is *not*\n // theme-inverted the way the monochrome brand marks above are — the\n // config schema tells authors to supply an image that reads on both\n // themes. Without an icon it falls back to a neutral sparkle rather than\n // nothing, so the label still has something to sit beside.\n const entry = custom.get(token);\n if (!entry) {\n // A token this deployment knows nothing about: ADR-0003 keeps it\n // visible as a label, but there is no honest mark to draw for it.\n return null;\n }\n if (!entry.icon) {\n return (\n <RiSparkling2Line\n size={size}\n className={className}\n style={{\n color: branded ? 'var(--bui-fg-primary)' : 'inherit',\n ...style,\n }}\n aria-label={entry.displayName}\n role=\"img\"\n />\n );\n }\n return (\n <img\n src={entry.icon}\n width={size}\n height={size}\n className={className}\n style={{ objectFit: 'contain', flexShrink: 0, ...style }}\n alt={entry.displayName}\n />\n );\n }\n\n const brandedColor = cfg.monochrome ? 'var(--bui-fg-primary)' : `#${cfg.hex}`;\n\n return (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n width={size}\n height={size}\n fill=\"currentColor\"\n className={className}\n style={{ color: branded ? brandedColor : 'inherit', ...style }}\n aria-label={cfg.label}\n role=\"img\"\n >\n <path d={cfg.path} />\n </svg>\n );\n}\n"],"names":[],"mappings":";;;;;;AAeA,MAAM,WAAA,GACJ,uTAAA;AAYF,MAAM,SAAA,GAGF;AAAA,EACF,eAAe,EAAE,GAAG,aAAa,KAAA,EAAO,aAAA,EAAe,YAAY,IAAA,EAAK;AAAA,EACxE,kBAAkB,EAAE,GAAG,UAAU,KAAA,EAAO,gBAAA,EAAkB,YAAY,IAAA,EAAK;AAAA,EAC3E,eAAA,EAAiB,EAAE,GAAG,cAAA,EAAgB,OAAO,eAAA;AAC/C,CAAA;AAkBO,SAAS,QAAA,CAAS;AAAA,EACvB,IAAA;AAAA,EACA,OAAA,GAAU,IAAA;AAAA,EACV,SAAA;AAAA,EACA,KAAA;AAAA,EACA,IAAA,GAAO;AACT,CAAA,EAAkB;AAChB,EAAA,MAAM,SAAS,mBAAA,EAAoB;AACnC,EAAA,MAAM,KAAA,GAAQ,mBAAmB,IAAI,CAAA;AAErC,EAAA,IAAI,UAAU,KAAA,EAAO;AACnB,IAAA,uBACE,GAAA;AAAA,MAAC,cAAA;AAAA,MAAA;AAAA,QACC,IAAA;AAAA,QACA,SAAA;AAAA,QACA,KAAA,EAAO,EAAE,KAAA,EAAO,yBAAA,EAA2B,GAAG,KAAA,EAAM;AAAA,QACpD,YAAA,EAAW,WAAA;AAAA,QAKX,IAAA,EAAK;AAAA;AAAA,KACP;AAAA,EAEJ;AAEA,EAAA,IAAI,UAAU,UAAA,EAAY;AAIxB,IAAA,uBACE,GAAA;AAAA,MAAC,iBAAA;AAAA,MAAA;AAAA,QACC,IAAA;AAAA,QACA,SAAA;AAAA,QACA,KAAA,EAAO;AAAA,UACL,KAAA,EAAO,UAAU,uBAAA,GAA0B,SAAA;AAAA,UAC3C,GAAG;AAAA,SACL;AAAA,QACA,YAAA,EAAW,UAAA;AAAA,QACX,IAAA,EAAK;AAAA;AAAA,KACP;AAAA,EAEJ;AAEA,EAAA,IAAI,UAAU,QAAA,EAAU;AACtB,IAAA,uBACE,GAAA;AAAA,MAAC,KAAA;AAAA,MAAA;AAAA,QACC,KAAA,EAAM,4BAAA;AAAA,QACN,OAAA,EAAQ,WAAA;AAAA,QACR,KAAA,EAAO,IAAA;AAAA,QACP,MAAA,EAAQ,IAAA;AAAA,QACR,IAAA,EAAK,cAAA;AAAA,QACL,SAAA;AAAA,QACA,KAAA,EAAO;AAAA,UACL,KAAA,EAAO,UAAU,uBAAA,GAA0B,SAAA;AAAA,UAC3C,GAAG;AAAA,SACL;AAAA,QACA,YAAA,EAAW,QAAA;AAAA,QACX,IAAA,EAAK,KAAA;AAAA,QAEL,QAAA,kBAAA,GAAA,CAAC,MAAA,EAAA,EAAK,CAAA,EAAG,WAAA,EAAa;AAAA;AAAA,KACxB;AAAA,EAEJ;AAEA,EAAA,MAAM,GAAA,GAAM,UAAU,KAAgB,CAAA;AACtC,EAAA,IAAI,CAAC,GAAA,EAAK;AAOR,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,KAAK,CAAA;AAC9B,IAAA,IAAI,CAAC,KAAA,EAAO;AAGV,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,IAAI,CAAC,MAAM,IAAA,EAAM;AACf,MAAA,uBACE,GAAA;AAAA,QAAC,gBAAA;AAAA,QAAA;AAAA,UACC,IAAA;AAAA,UACA,SAAA;AAAA,UACA,KAAA,EAAO;AAAA,YACL,KAAA,EAAO,UAAU,uBAAA,GAA0B,SAAA;AAAA,YAC3C,GAAG;AAAA,WACL;AAAA,UACA,cAAY,KAAA,CAAM,WAAA;AAAA,UAClB,IAAA,EAAK;AAAA;AAAA,OACP;AAAA,IAEJ;AACA,IAAA,uBACE,GAAA;AAAA,MAAC,KAAA;AAAA,MAAA;AAAA,QACC,KAAK,KAAA,CAAM,IAAA;AAAA,QACX,KAAA,EAAO,IAAA;AAAA,QACP,MAAA,EAAQ,IAAA;AAAA,QACR,SAAA;AAAA,QACA,OAAO,EAAE,SAAA,EAAW,WAAW,UAAA,EAAY,CAAA,EAAG,GAAG,KAAA,EAAM;AAAA,QACvD,KAAK,KAAA,CAAM;AAAA;AAAA,KACb;AAAA,EAEJ;AAEA,EAAA,MAAM,eAAe,GAAA,CAAI,UAAA,GAAa,uBAAA,GAA0B,CAAA,CAAA,EAAI,IAAI,GAAG,CAAA,CAAA;AAE3E,EAAA,uBACE,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACC,KAAA,EAAM,4BAAA;AAAA,MACN,OAAA,EAAQ,WAAA;AAAA,MACR,KAAA,EAAO,IAAA;AAAA,MACP,MAAA,EAAQ,IAAA;AAAA,MACR,IAAA,EAAK,cAAA;AAAA,MACL,SAAA;AAAA,MACA,OAAO,EAAE,KAAA,EAAO,UAAU,YAAA,GAAe,SAAA,EAAW,GAAG,KAAA,EAAM;AAAA,MAC7D,cAAY,GAAA,CAAI,KAAA;AAAA,MAChB,IAAA,EAAK,KAAA;AAAA,MAEL,QAAA,kBAAA,GAAA,CAAC,MAAA,EAAA,EAAK,CAAA,EAAG,GAAA,CAAI,IAAA,EAAM;AAAA;AAAA,GACrB;AAEJ;;;;"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useApi, configApiRef } from '@backstage/core-plugin-api';
|
|
3
|
+
import { DEFAULT_SORT_MODE, isSortMode, SORT_MODES, NATURAL_SORT_DIRECTION } from '@nospt/plugin-dev-ai-hub-common';
|
|
4
|
+
|
|
5
|
+
function useDefaultSortMode() {
|
|
6
|
+
const config = useApi(configApiRef);
|
|
7
|
+
return useMemo(() => {
|
|
8
|
+
const configured = config.getOptional("devAiHub.ui.defaultSort");
|
|
9
|
+
if (configured === void 0) return DEFAULT_SORT_MODE;
|
|
10
|
+
if (isSortMode(configured)) return configured;
|
|
11
|
+
console.error(
|
|
12
|
+
`[dev-ai-hub] Invalid devAiHub.ui.defaultSort: ${JSON.stringify(
|
|
13
|
+
configured
|
|
14
|
+
)}. Expected one of: ${SORT_MODES.join(
|
|
15
|
+
", "
|
|
16
|
+
)}. Falling back to ${DEFAULT_SORT_MODE}.`
|
|
17
|
+
);
|
|
18
|
+
return DEFAULT_SORT_MODE;
|
|
19
|
+
}, [config]);
|
|
20
|
+
}
|
|
21
|
+
function useDefaultSortDirection(mode) {
|
|
22
|
+
const config = useApi(configApiRef);
|
|
23
|
+
return useMemo(() => {
|
|
24
|
+
const configured = config.getOptional("devAiHub.ui.defaultSortDirection");
|
|
25
|
+
if (configured === void 0) return NATURAL_SORT_DIRECTION[mode];
|
|
26
|
+
if (configured === "asc" || configured === "desc") return configured;
|
|
27
|
+
console.error(
|
|
28
|
+
`[dev-ai-hub] Invalid devAiHub.ui.defaultSortDirection: ${JSON.stringify(
|
|
29
|
+
configured
|
|
30
|
+
)}. Expected one of: asc, desc. Falling back to ${NATURAL_SORT_DIRECTION[mode]}.`
|
|
31
|
+
);
|
|
32
|
+
return NATURAL_SORT_DIRECTION[mode];
|
|
33
|
+
}, [config, mode]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { useDefaultSortDirection, useDefaultSortMode };
|
|
37
|
+
//# sourceMappingURL=useDefaultSortMode.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useDefaultSortMode.esm.js","sources":["../../src/hooks/useDefaultSortMode.ts"],"sourcesContent":["import { useMemo } from 'react';\nimport { configApiRef, useApi } from '@backstage/core-plugin-api';\nimport {\n DEFAULT_SORT_MODE,\n NATURAL_SORT_DIRECTION,\n isSortMode,\n SORT_MODES,\n type SortDirection,\n type SortMode,\n} from '@nospt/plugin-dev-ai-hub-common';\n\n/**\n * The configured default sort mode for the browse page (issue #102),\n * read from `devAiHub.ui.defaultSort`. Only supplies the *initial* sort —\n * once a user picks a different mode from the page's sort dropdown, that\n * selection wins for the rest of the view (no persistence back to config).\n *\n * An unset value falls back to `DEFAULT_SORT_MODE` (\"usage\") with no error,\n * since that is the documented default. A *set but unrecognised* value\n * (e.g. a typo) also falls back to `DEFAULT_SORT_MODE`, but is logged as a\n * clear, actionable error — a misconfigured default sort must never blank\n * the page.\n */\nexport function useDefaultSortMode(): SortMode {\n const config = useApi(configApiRef);\n\n return useMemo(() => {\n const configured = config.getOptional('devAiHub.ui.defaultSort');\n if (configured === undefined) return DEFAULT_SORT_MODE;\n\n if (isSortMode(configured)) return configured;\n\n // eslint-disable-next-line no-console\n console.error(\n `[dev-ai-hub] Invalid devAiHub.ui.defaultSort: ${JSON.stringify(\n configured,\n )}. Expected one of: ${SORT_MODES.join(\n ', ',\n )}. Falling back to ${DEFAULT_SORT_MODE}.`,\n );\n return DEFAULT_SORT_MODE;\n }, [config]);\n}\n\nexport function useDefaultSortDirection(mode: SortMode): SortDirection {\n const config = useApi(configApiRef);\n\n return useMemo(() => {\n const configured = config.getOptional('devAiHub.ui.defaultSortDirection');\n if (configured === undefined) return NATURAL_SORT_DIRECTION[mode];\n\n if (configured === 'asc' || configured === 'desc') return configured;\n\n // eslint-disable-next-line no-console\n console.error(\n `[dev-ai-hub] Invalid devAiHub.ui.defaultSortDirection: ${JSON.stringify(\n configured,\n )}. Expected one of: asc, desc. Falling back to ${\n NATURAL_SORT_DIRECTION[mode]\n }.`,\n );\n return NATURAL_SORT_DIRECTION[mode];\n }, [config, mode]);\n}\n"],"names":[],"mappings":";;;;AAuBO,SAAS,kBAAA,GAA+B;AAC7C,EAAA,MAAM,MAAA,GAAS,OAAO,YAAY,CAAA;AAElC,EAAA,OAAO,QAAQ,MAAM;AACnB,IAAA,MAAM,UAAA,GAAa,MAAA,CAAO,WAAA,CAAY,yBAAyB,CAAA;AAC/D,IAAA,IAAI,UAAA,KAAe,QAAW,OAAO,iBAAA;AAErC,IAAA,IAAI,UAAA,CAAW,UAAU,CAAA,EAAG,OAAO,UAAA;AAGnC,IAAA,OAAA,CAAQ,KAAA;AAAA,MACN,iDAAiD,IAAA,CAAK,SAAA;AAAA,QACpD;AAAA,OACD,sBAAsB,UAAA,CAAW,IAAA;AAAA,QAChC;AAAA,OACD,qBAAqB,iBAAiB,CAAA,CAAA;AAAA,KACzC;AACA,IAAA,OAAO,iBAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AACb;AAEO,SAAS,wBAAwB,IAAA,EAA+B;AACrE,EAAA,MAAM,MAAA,GAAS,OAAO,YAAY,CAAA;AAElC,EAAA,OAAO,QAAQ,MAAM;AACnB,IAAA,MAAM,UAAA,GAAa,MAAA,CAAO,WAAA,CAAY,kCAAkC,CAAA;AACxE,IAAA,IAAI,UAAA,KAAe,MAAA,EAAW,OAAO,sBAAA,CAAuB,IAAI,CAAA;AAEhE,IAAA,IAAI,UAAA,KAAe,KAAA,IAAS,UAAA,KAAe,MAAA,EAAQ,OAAO,UAAA;AAG1D,IAAA,OAAA,CAAQ,KAAA;AAAA,MACN,0DAA0D,IAAA,CAAK,SAAA;AAAA,QAC7D;AAAA,OACD,CAAA,8CAAA,EACC,sBAAA,CAAuB,IAAI,CAC7B,CAAA,CAAA;AAAA,KACF;AACA,IAAA,OAAO,uBAAuB,IAAI,CAAA;AAAA,EACpC,CAAA,EAAG,CAAC,MAAA,EAAQ,IAAI,CAAC,CAAA;AACnB;;;;"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useApi, configApiRef } from '@backstage/core-plugin-api';
|
|
3
|
+
import { normalizeFramework, parseCustomFrameworks } from '@nospt/plugin-dev-ai-hub-common';
|
|
4
|
+
|
|
5
|
+
const loggedCustomFrameworkWarnings = /* @__PURE__ */ new Set();
|
|
6
|
+
function useCustomFrameworks() {
|
|
7
|
+
const config = useApi(configApiRef);
|
|
8
|
+
return useMemo(() => {
|
|
9
|
+
const { frameworks, warnings } = parseCustomFrameworks(
|
|
10
|
+
config.getOptional("devAiHub.frameworks.custom")
|
|
11
|
+
);
|
|
12
|
+
for (const warning of warnings) {
|
|
13
|
+
if (loggedCustomFrameworkWarnings.has(warning)) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
loggedCustomFrameworkWarnings.add(warning);
|
|
17
|
+
console.error(`[dev-ai-hub] ${warning}`);
|
|
18
|
+
}
|
|
19
|
+
return new Map(frameworks.map((f) => [f.name, f]));
|
|
20
|
+
}, [config]);
|
|
21
|
+
}
|
|
22
|
+
const BUILT_IN_LABELS = {
|
|
23
|
+
"claude-code": "Claude Code",
|
|
24
|
+
"github-copilot": "GitHub Copilot",
|
|
25
|
+
"google-gemini": "Google Gemini",
|
|
26
|
+
cursor: "Cursor",
|
|
27
|
+
opencode: "OpenCode",
|
|
28
|
+
all: "All tools"
|
|
29
|
+
};
|
|
30
|
+
function useFrameworkLabel() {
|
|
31
|
+
const custom = useCustomFrameworks();
|
|
32
|
+
return useMemo(
|
|
33
|
+
() => (token) => {
|
|
34
|
+
const normalized = normalizeFramework(token);
|
|
35
|
+
return BUILT_IN_LABELS[normalized] ?? custom.get(normalized)?.displayName ?? token;
|
|
36
|
+
},
|
|
37
|
+
[custom]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { useCustomFrameworks, useFrameworkLabel };
|
|
42
|
+
//# sourceMappingURL=useFrameworkLabel.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFrameworkLabel.esm.js","sources":["../../src/hooks/useFrameworkLabel.ts"],"sourcesContent":["import { useMemo } from 'react';\nimport { configApiRef, useApi } from '@backstage/core-plugin-api';\nimport {\n normalizeFramework,\n parseCustomFrameworks,\n type CustomFramework,\n} from '@nospt/plugin-dev-ai-hub-common';\n\nconst loggedCustomFrameworkWarnings = new Set<string>();\n\n/**\n * The deployment-registered frameworks (`devAiHub.frameworks.custom`,\n * ADR-0018), keyed by their normalised token.\n *\n * Parsed with the same `-common` helper the backend runs, so the two can never\n * disagree about which tokens exist — the backend decides whether a custom\n * token survives the allow-list, and this decides how it renders. Dropped\n * entries are logged once here (matching `useDefaultSortMode`'s treatment of a\n * bad config value); the backend logs them server-side too.\n */\nexport function useCustomFrameworks(): Map<string, CustomFramework> {\n const config = useApi(configApiRef);\n\n return useMemo(() => {\n const { frameworks, warnings } = parseCustomFrameworks(\n config.getOptional('devAiHub.frameworks.custom'),\n );\n for (const warning of warnings) {\n if (loggedCustomFrameworkWarnings.has(warning)) {\n continue;\n }\n loggedCustomFrameworkWarnings.add(warning);\n // eslint-disable-next-line no-console\n console.error(`[dev-ai-hub] ${warning}`);\n }\n return new Map(frameworks.map(f => [f.name, f]));\n }, [config]);\n}\n\n/** Display labels for the framework tokens built into the plugin. */\nconst BUILT_IN_LABELS: Record<string, string> = {\n 'claude-code': 'Claude Code',\n 'github-copilot': 'GitHub Copilot',\n 'google-gemini': 'Google Gemini',\n cursor: 'Cursor',\n opencode: 'OpenCode',\n all: 'All tools',\n};\n\n/**\n * Resolve a framework token to its display label: a built-in's name, a custom\n * framework's configured `displayName`, or the raw token when neither\n * applies (ADR-0003 keeps unknown tokens visible rather than dropping them).\n */\nexport function useFrameworkLabel(): (token: string) => string {\n const custom = useCustomFrameworks();\n\n return useMemo(\n () => (token: string) => {\n const normalized = normalizeFramework(token);\n return (\n BUILT_IN_LABELS[normalized] ??\n custom.get(normalized)?.displayName ??\n token\n );\n },\n [custom],\n );\n}\n"],"names":[],"mappings":";;;;AAQA,MAAM,6BAAA,uBAAoC,GAAA,EAAY;AAY/C,SAAS,mBAAA,GAAoD;AAClE,EAAA,MAAM,MAAA,GAAS,OAAO,YAAY,CAAA;AAElC,EAAA,OAAO,QAAQ,MAAM;AACnB,IAAA,MAAM,EAAE,UAAA,EAAY,QAAA,EAAS,GAAI,qBAAA;AAAA,MAC/B,MAAA,CAAO,YAAY,4BAA4B;AAAA,KACjD;AACA,IAAA,KAAA,MAAW,WAAW,QAAA,EAAU;AAC9B,MAAA,IAAI,6BAAA,CAA8B,GAAA,CAAI,OAAO,CAAA,EAAG;AAC9C,QAAA;AAAA,MACF;AACA,MAAA,6BAAA,CAA8B,IAAI,OAAO,CAAA;AAEzC,MAAA,OAAA,CAAQ,KAAA,CAAM,CAAA,aAAA,EAAgB,OAAO,CAAA,CAAE,CAAA;AAAA,IACzC;AACA,IAAA,OAAO,IAAI,GAAA,CAAI,UAAA,CAAW,GAAA,CAAI,CAAA,CAAA,KAAK,CAAC,CAAA,CAAE,IAAA,EAAM,CAAC,CAAC,CAAC,CAAA;AAAA,EACjD,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AACb;AAGA,MAAM,eAAA,GAA0C;AAAA,EAC9C,aAAA,EAAe,aAAA;AAAA,EACf,gBAAA,EAAkB,gBAAA;AAAA,EAClB,eAAA,EAAiB,eAAA;AAAA,EACjB,MAAA,EAAQ,QAAA;AAAA,EACR,QAAA,EAAU,UAAA;AAAA,EACV,GAAA,EAAK;AACP,CAAA;AAOO,SAAS,iBAAA,GAA+C;AAC7D,EAAA,MAAM,SAAS,mBAAA,EAAoB;AAEnC,EAAA,OAAO,OAAA;AAAA,IACL,MAAM,CAAC,KAAA,KAAkB;AACvB,MAAA,MAAM,UAAA,GAAa,mBAAmB,KAAK,CAAA;AAC3C,MAAA,OACE,gBAAgB,UAAU,CAAA,IAC1B,OAAO,GAAA,CAAI,UAAU,GAAG,WAAA,IACxB,KAAA;AAAA,IAEJ,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AACF;;;;"}
|
|
@@ -1,25 +1,36 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
2
|
import { useApi } from '@backstage/core-plugin-api';
|
|
3
3
|
import { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient.esm.js';
|
|
4
4
|
|
|
5
|
-
function useResources() {
|
|
5
|
+
function useResources(options) {
|
|
6
6
|
const api = useApi(devAiHubResourceApiRef);
|
|
7
|
+
const includeExcluded = options?.includeExcluded ?? false;
|
|
7
8
|
const [items, setItems] = useState(void 0);
|
|
8
9
|
const [error, setError] = useState(void 0);
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const requestId = useRef(0);
|
|
11
|
+
const load = useCallback(async () => {
|
|
12
|
+
const id = ++requestId.current;
|
|
11
13
|
setItems(void 0);
|
|
12
14
|
setError(void 0);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
}, [api]);
|
|
22
|
-
|
|
15
|
+
try {
|
|
16
|
+
const result = await api.getResources({ includeExcluded });
|
|
17
|
+
if (requestId.current === id) setItems(result);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (requestId.current === id) {
|
|
20
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}, [api, includeExcluded]);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
load();
|
|
26
|
+
}, [api, includeExcluded]);
|
|
27
|
+
return {
|
|
28
|
+
items,
|
|
29
|
+
error,
|
|
30
|
+
loading: items === void 0 && error === void 0,
|
|
31
|
+
/** Re-fetches the list — for a caller (e.g. curation) that just wrote. */
|
|
32
|
+
refresh: load
|
|
33
|
+
};
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export { useResources };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useResources.esm.js","sources":["../../src/hooks/useResources.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport type { ResourceSummary } from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient';\n\nexport function useResources() {\n const api = useApi(devAiHubResourceApiRef);\n const [items, setItems] = useState<ResourceSummary[] | undefined>(undefined);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n
|
|
1
|
+
{"version":3,"file":"useResources.esm.js","sources":["../../src/hooks/useResources.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport type { ResourceSummary } from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient';\n\nexport function useResources(options?: { includeExcluded?: boolean }) {\n const api = useApi(devAiHubResourceApiRef);\n // Read into a primitive so a caller passing an inline object literal does\n // not re-trigger the fetch on every render.\n const includeExcluded = options?.includeExcluded ?? false;\n const [items, setItems] = useState<ResourceSummary[] | undefined>(undefined);\n const [error, setError] = useState<Error | undefined>(undefined);\n // Guards against a stale fetch — whether superseded by the api ref\n // changing or by a manual `refresh()` — clobbering a fresher one that\n // already resolved.\n const requestId = useRef(0);\n\n const load = useCallback(async () => {\n const id = ++requestId.current;\n setItems(undefined);\n setError(undefined);\n try {\n const result = await api.getResources({ includeExcluded });\n if (requestId.current === id) setItems(result);\n } catch (e) {\n if (requestId.current === id) {\n setError(e instanceof Error ? e : new Error(String(e)));\n }\n }\n }, [api, includeExcluded]);\n\n useEffect(() => {\n load();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [api, includeExcluded]);\n\n return {\n items,\n error,\n loading: items === undefined && error === undefined,\n /** Re-fetches the list — for a caller (e.g. curation) that just wrote. */\n refresh: load,\n };\n}\n"],"names":[],"mappings":";;;;AAKO,SAAS,aAAa,OAAA,EAAyC;AACpE,EAAA,MAAM,GAAA,GAAM,OAAO,sBAAsB,CAAA;AAGzC,EAAA,MAAM,eAAA,GAAkB,SAAS,eAAA,IAAmB,KAAA;AACpD,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAI,SAAwC,MAAS,CAAA;AAC3E,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAI,SAA4B,MAAS,CAAA;AAI/D,EAAA,MAAM,SAAA,GAAY,OAAO,CAAC,CAAA;AAE1B,EAAA,MAAM,IAAA,GAAO,YAAY,YAAY;AACnC,IAAA,MAAM,EAAA,GAAK,EAAE,SAAA,CAAU,OAAA;AACvB,IAAA,QAAA,CAAS,MAAS,CAAA;AAClB,IAAA,QAAA,CAAS,MAAS,CAAA;AAClB,IAAA,IAAI;AACF,MAAA,MAAM,SAAS,MAAM,GAAA,CAAI,YAAA,CAAa,EAAE,iBAAiB,CAAA;AACzD,MAAA,IAAI,SAAA,CAAU,OAAA,KAAY,EAAA,EAAI,QAAA,CAAS,MAAM,CAAA;AAAA,IAC/C,SAAS,CAAA,EAAG;AACV,MAAA,IAAI,SAAA,CAAU,YAAY,EAAA,EAAI;AAC5B,QAAA,QAAA,CAAS,CAAA,YAAa,QAAQ,CAAA,GAAI,IAAI,MAAM,MAAA,CAAO,CAAC,CAAC,CAAC,CAAA;AAAA,MACxD;AAAA,IACF;AAAA,EACF,CAAA,EAAG,CAAC,GAAA,EAAK,eAAe,CAAC,CAAA;AAEzB,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAA,EAAK;AAAA,EAEP,CAAA,EAAG,CAAC,GAAA,EAAK,eAAe,CAAC,CAAA;AAEzB,EAAA,OAAO;AAAA,IACL,KAAA;AAAA,IACA,KAAA;AAAA,IACA,OAAA,EAAS,KAAA,KAAU,MAAA,IAAa,KAAA,KAAU,MAAA;AAAA;AAAA,IAE1C,OAAA,EAAS;AAAA,GACX;AACF;;;;"}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { useState, useEffect
|
|
1
|
+
import { useCallback, useState, useEffect } from 'react';
|
|
2
2
|
import { useApi } from '@backstage/core-plugin-api';
|
|
3
3
|
import { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient.esm.js';
|
|
4
4
|
|
|
5
5
|
const listeners = /* @__PURE__ */ new Map();
|
|
6
|
+
const anyChangeListeners = /* @__PURE__ */ new Set();
|
|
6
7
|
function notifyTelemetryChanged(entityRef) {
|
|
7
8
|
listeners.get(entityRef)?.forEach((listener) => listener());
|
|
9
|
+
anyChangeListeners.forEach((listener) => listener());
|
|
10
|
+
}
|
|
11
|
+
function subscribeToAnyTelemetryChange(listener) {
|
|
12
|
+
anyChangeListeners.add(listener);
|
|
13
|
+
return () => {
|
|
14
|
+
anyChangeListeners.delete(listener);
|
|
15
|
+
};
|
|
8
16
|
}
|
|
9
17
|
function useTrackTelemetry() {
|
|
10
18
|
const api = useApi(devAiHubResourceApiRef);
|
|
@@ -13,13 +21,13 @@ function useTrackTelemetry() {
|
|
|
13
21
|
[api]
|
|
14
22
|
);
|
|
15
23
|
}
|
|
16
|
-
function useTelemetryCounts(entityRef) {
|
|
24
|
+
function useTelemetryCounts(entityRef, seedCounts) {
|
|
17
25
|
const api = useApi(devAiHubResourceApiRef);
|
|
18
|
-
const [counts, setCounts] = useState();
|
|
26
|
+
const [counts, setCounts] = useState(seedCounts);
|
|
19
27
|
const [revision, setRevision] = useState(0);
|
|
20
28
|
useEffect(() => {
|
|
21
|
-
setCounts(
|
|
22
|
-
}, [entityRef]);
|
|
29
|
+
setCounts(seedCounts);
|
|
30
|
+
}, [entityRef, seedCounts]);
|
|
23
31
|
useEffect(() => {
|
|
24
32
|
if (!entityRef) return void 0;
|
|
25
33
|
const bump = () => setRevision((r) => r + 1);
|
|
@@ -33,6 +41,7 @@ function useTelemetryCounts(entityRef) {
|
|
|
33
41
|
}, [entityRef]);
|
|
34
42
|
useEffect(() => {
|
|
35
43
|
if (!entityRef) return void 0;
|
|
44
|
+
if (seedCounts !== void 0) return void 0;
|
|
36
45
|
let cancelled = false;
|
|
37
46
|
api.getInstallCount(entityRef).then((result) => {
|
|
38
47
|
if (!cancelled) setCounts(result);
|
|
@@ -41,9 +50,9 @@ function useTelemetryCounts(entityRef) {
|
|
|
41
50
|
return () => {
|
|
42
51
|
cancelled = true;
|
|
43
52
|
};
|
|
44
|
-
}, [api, entityRef, revision]);
|
|
53
|
+
}, [api, entityRef, revision, seedCounts]);
|
|
45
54
|
return counts;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
export { notifyTelemetryChanged, useTelemetryCounts, useTrackTelemetry };
|
|
57
|
+
export { notifyTelemetryChanged, subscribeToAnyTelemetryChange, useTelemetryCounts, useTrackTelemetry };
|
|
49
58
|
//# sourceMappingURL=useTelemetryCounts.esm.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useTelemetryCounts.esm.js","sources":["../../src/hooks/useTelemetryCounts.ts"],"sourcesContent":["import { useCallback, useEffect, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport type {\n TelemetryAction,\n TelemetryCounts,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient';\n\n/**\n * Subscribers per entityRef. Module-scoped: a resource's counts are shown by\n * more than one component at once — the grid card and, once it is open, the\n * detail drawer — and a write from either has to move both.\n */\nconst listeners = new Map<string, Set<() => void>>();\n\n/**\n * Tell every mounted `useTelemetryCounts` for this resource to refetch.\n *\n * Call it once a telemetry write has landed, not before: counts are read back\n * from the server rather than incremented locally, so the number shown is\n * always the server's and a failed write simply leaves it where it was.\n */\nexport function notifyTelemetryChanged(entityRef: string): void {\n listeners.get(entityRef)?.forEach(listener => listener());\n}\n\n/**\n * Record a telemetry event and then move every count on screen for that\n * resource. Use this rather than `api.track` directly: it is the one place\n * the write is paired with the refetch, so a new call site cannot forget it\n * and leave a stale number behind.\n *\n * Still fire-and-forget from the caller's point of view — `track` swallows\n * its own failures (ADR-0007), and a write that never landed just leaves the\n * refetched count unchanged.\n */\nexport function useTrackTelemetry() {\n const api = useApi(devAiHubResourceApiRef);\n // Rest-spread rather than named params: forwarding `tool` explicitly would\n // pass an extra `undefined` on the calls that omit it, changing `track`'s\n // observed arity for no behavioural gain.\n return useCallback(\n (entityRef: string, ...rest: [action: TelemetryAction, tool?: string]) =>\n api\n .track(entityRef, ...rest)\n .then(() => notifyTelemetryChanged(entityRef)),\n [api],\n );\n}\n\n/**\n * Raw per-action telemetry counts for a resource (ADR-0007). Fetch failures\n * are swallowed — a missing count is not worth surfacing as a UI error, the\n * card/detail just renders without it.\n *\n * Refetches when `notifyTelemetryChanged` fires for the same resource, so\n * opening or installing a resource moves its count in place instead of\n * waiting for the next page load.\n */\nexport function useTelemetryCounts(\n entityRef: string | undefined,\n): TelemetryCounts | undefined {\n const api = useApi(devAiHubResourceApiRef);\n const [counts, setCounts] = useState<TelemetryCounts>();\n const [revision, setRevision] = useState(0);\n\n //
|
|
1
|
+
{"version":3,"file":"useTelemetryCounts.esm.js","sources":["../../src/hooks/useTelemetryCounts.ts"],"sourcesContent":["import { useCallback, useEffect, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport type {\n TelemetryAction,\n TelemetryCounts,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient';\n\n/**\n * Subscribers per entityRef. Module-scoped: a resource's counts are shown by\n * more than one component at once — the grid card and, once it is open, the\n * detail drawer — and a write from either has to move both.\n */\nconst listeners = new Map<string, Set<() => void>>();\n\n/**\n * Subscribers interested in *any* telemetry write, regardless of which\n * resource it landed on. Module-scoped like `listeners` above, but keyed on\n * nothing in particular — this is how `useTelemetrySummary` (the page-level\n * fetch that drives sort, issue #102) knows to refresh: it shows every\n * resource at once, so it cannot subscribe per-entityRef the way a single\n * card or the drawer does.\n */\nconst anyChangeListeners = new Set<() => void>();\n\n/**\n * Tell every mounted `useTelemetryCounts` for this resource to refetch, and\n * every page-level listener (`subscribeToAnyTelemetryChange`) that some\n * resource's counts moved.\n *\n * Call it once a telemetry write has landed, not before: counts are read back\n * from the server rather than incremented locally, so the number shown is\n * always the server's and a failed write simply leaves it where it was.\n */\nexport function notifyTelemetryChanged(entityRef: string): void {\n listeners.get(entityRef)?.forEach(listener => listener());\n anyChangeListeners.forEach(listener => listener());\n}\n\n/**\n * Subscribe to every telemetry write, across all resources. Returns an\n * unsubscribe function. Used by `useTelemetrySummary` to re-fetch the\n * catalog-wide totals a view/install/copy/download just changed, rather than\n * leaving the sort-by-usage order stale until the next full page load.\n */\nexport function subscribeToAnyTelemetryChange(\n listener: () => void,\n): () => void {\n anyChangeListeners.add(listener);\n return () => {\n anyChangeListeners.delete(listener);\n };\n}\n\n/**\n * Record a telemetry event and then move every count on screen for that\n * resource. Use this rather than `api.track` directly: it is the one place\n * the write is paired with the refetch, so a new call site cannot forget it\n * and leave a stale number behind.\n *\n * Still fire-and-forget from the caller's point of view — `track` swallows\n * its own failures (ADR-0007), and a write that never landed just leaves the\n * refetched count unchanged.\n */\nexport function useTrackTelemetry() {\n const api = useApi(devAiHubResourceApiRef);\n // Rest-spread rather than named params: forwarding `tool` explicitly would\n // pass an extra `undefined` on the calls that omit it, changing `track`'s\n // observed arity for no behavioural gain.\n return useCallback(\n (entityRef: string, ...rest: [action: TelemetryAction, tool?: string]) =>\n api\n .track(entityRef, ...rest)\n .then(() => notifyTelemetryChanged(entityRef)),\n [api],\n );\n}\n\n/**\n * Raw per-action telemetry counts for a resource (ADR-0007). Fetch failures\n * are swallowed — a missing count is not worth surfacing as a UI error, the\n * card/detail just renders without it.\n *\n * Refetches when `notifyTelemetryChanged` fires for the same resource, so\n * opening or installing a resource moves its count in place instead of\n * waiting for the next page load.\n *\n * `seedCounts`, when passed, is this resource's slice of a shared page-level\n * fetch (`AiResourcesPage`'s telemetry summary, used to drive sort) — the\n * grid card reads from that instead of firing its own request, cutting one\n * network round trip per visible card down to the page's single aggregate\n * fetch. The drawer (`ResourceDetailPanel`) has no such shared fetch (it\n * shows one resource at a time) and omits the argument, so it still fetches\n * for itself exactly as before. While a seed is present, this hook treats it\n * as authoritative and waits for the next shared-summary refresh instead of\n * issuing per-card reads on every telemetry notification.\n */\nexport function useTelemetryCounts(\n entityRef: string | undefined,\n seedCounts?: TelemetryCounts,\n): TelemetryCounts | undefined {\n const api = useApi(devAiHubResourceApiRef);\n const [counts, setCounts] = useState<TelemetryCounts | undefined>(seedCounts);\n const [revision, setRevision] = useState(0);\n\n // Reset to the current seed (or blank) when either side changes.\n // Deliberately not folded into the fetch effect below: that one also runs\n // per revision, and resetting there would flash the count away on every\n // write.\n useEffect(() => {\n setCounts(seedCounts);\n }, [entityRef, seedCounts]);\n\n useEffect(() => {\n if (!entityRef) return undefined;\n\n const bump = () => setRevision(r => r + 1);\n const subscribers = listeners.get(entityRef) ?? new Set<() => void>();\n subscribers.add(bump);\n listeners.set(entityRef, subscribers);\n\n return () => {\n subscribers.delete(bump);\n if (subscribers.size === 0) listeners.delete(entityRef);\n };\n }, [entityRef]);\n\n useEffect(() => {\n if (!entityRef) return undefined;\n\n // Seeded from the shared page-level fetch — skip this component's own\n // request and trust the next summary refresh to move the counts.\n if (seedCounts !== undefined) return undefined;\n\n let cancelled = false;\n api\n .getInstallCount(entityRef)\n .then(result => {\n if (!cancelled) setCounts(result);\n })\n .catch(() => {\n // ignored — see above\n });\n return () => {\n cancelled = true;\n };\n }, [api, entityRef, revision, seedCounts]);\n\n return counts;\n}\n"],"names":[],"mappings":";;;;AAaA,MAAM,SAAA,uBAAgB,GAAA,EAA6B;AAUnD,MAAM,kBAAA,uBAAyB,GAAA,EAAgB;AAWxC,SAAS,uBAAuB,SAAA,EAAyB;AAC9D,EAAA,SAAA,CAAU,IAAI,SAAS,CAAA,EAAG,OAAA,CAAQ,CAAA,QAAA,KAAY,UAAU,CAAA;AACxD,EAAA,kBAAA,CAAmB,OAAA,CAAQ,CAAA,QAAA,KAAY,QAAA,EAAU,CAAA;AACnD;AAQO,SAAS,8BACd,QAAA,EACY;AACZ,EAAA,kBAAA,CAAmB,IAAI,QAAQ,CAAA;AAC/B,EAAA,OAAO,MAAM;AACX,IAAA,kBAAA,CAAmB,OAAO,QAAQ,CAAA;AAAA,EACpC,CAAA;AACF;AAYO,SAAS,iBAAA,GAAoB;AAClC,EAAA,MAAM,GAAA,GAAM,OAAO,sBAAsB,CAAA;AAIzC,EAAA,OAAO,WAAA;AAAA,IACL,CAAC,SAAA,EAAA,GAAsB,IAAA,KACrB,GAAA,CACG,KAAA,CAAM,SAAA,EAAW,GAAG,IAAI,CAAA,CACxB,IAAA,CAAK,MAAM,sBAAA,CAAuB,SAAS,CAAC,CAAA;AAAA,IACjD,CAAC,GAAG;AAAA,GACN;AACF;AAqBO,SAAS,kBAAA,CACd,WACA,UAAA,EAC6B;AAC7B,EAAA,MAAM,GAAA,GAAM,OAAO,sBAAsB,CAAA;AACzC,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAsC,UAAU,CAAA;AAC5E,EAAA,MAAM,CAAC,QAAA,EAAU,WAAW,CAAA,GAAI,SAAS,CAAC,CAAA;AAM1C,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,SAAA,CAAU,UAAU,CAAA;AAAA,EACtB,CAAA,EAAG,CAAC,SAAA,EAAW,UAAU,CAAC,CAAA;AAE1B,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,CAAC,WAAW,OAAO,MAAA;AAEvB,IAAA,MAAM,IAAA,GAAO,MAAM,WAAA,CAAY,CAAA,CAAA,KAAK,IAAI,CAAC,CAAA;AACzC,IAAA,MAAM,cAAc,SAAA,CAAU,GAAA,CAAI,SAAS,CAAA,wBAAS,GAAA,EAAgB;AACpE,IAAA,WAAA,CAAY,IAAI,IAAI,CAAA;AACpB,IAAA,SAAA,CAAU,GAAA,CAAI,WAAW,WAAW,CAAA;AAEpC,IAAA,OAAO,MAAM;AACX,MAAA,WAAA,CAAY,OAAO,IAAI,CAAA;AACvB,MAAA,IAAI,WAAA,CAAY,IAAA,KAAS,CAAA,EAAG,SAAA,CAAU,OAAO,SAAS,CAAA;AAAA,IACxD,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,SAAS,CAAC,CAAA;AAEd,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,CAAC,WAAW,OAAO,MAAA;AAIvB,IAAA,IAAI,UAAA,KAAe,QAAW,OAAO,MAAA;AAErC,IAAA,IAAI,SAAA,GAAY,KAAA;AAChB,IAAA,GAAA,CACG,eAAA,CAAgB,SAAS,CAAA,CACzB,IAAA,CAAK,CAAA,MAAA,KAAU;AACd,MAAA,IAAI,CAAC,SAAA,EAAW,SAAA,CAAU,MAAM,CAAA;AAAA,IAClC,CAAC,CAAA,CACA,KAAA,CAAM,MAAM;AAAA,IAEb,CAAC,CAAA;AACH,IAAA,OAAO,MAAM;AACX,MAAA,SAAA,GAAY,IAAA;AAAA,IACd,CAAA;AAAA,EACF,GAAG,CAAC,GAAA,EAAK,SAAA,EAAW,QAAA,EAAU,UAAU,CAAC,CAAA;AAEzC,EAAA,OAAO,MAAA;AACT;;;;"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useApi } from '@backstage/core-plugin-api';
|
|
3
|
+
import { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient.esm.js';
|
|
4
|
+
import { subscribeToAnyTelemetryChange } from './useTelemetryCounts.esm.js';
|
|
5
|
+
|
|
6
|
+
function useTelemetrySummary() {
|
|
7
|
+
const api = useApi(devAiHubResourceApiRef);
|
|
8
|
+
const cancelInFlightRef = useRef(() => {
|
|
9
|
+
});
|
|
10
|
+
const [summary, setSummary] = useState(
|
|
11
|
+
void 0
|
|
12
|
+
);
|
|
13
|
+
const [error, setError] = useState(void 0);
|
|
14
|
+
const load = useCallback(() => {
|
|
15
|
+
cancelInFlightRef.current();
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
const cancel = () => {
|
|
18
|
+
cancelled = true;
|
|
19
|
+
};
|
|
20
|
+
cancelInFlightRef.current = cancel;
|
|
21
|
+
api.getTelemetrySummary().then((result) => {
|
|
22
|
+
if (!cancelled) {
|
|
23
|
+
setSummary(result);
|
|
24
|
+
setError(void 0);
|
|
25
|
+
}
|
|
26
|
+
}).catch((e) => {
|
|
27
|
+
if (!cancelled) {
|
|
28
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return cancel;
|
|
32
|
+
}, [api]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
load();
|
|
35
|
+
return () => cancelInFlightRef.current();
|
|
36
|
+
}, [load]);
|
|
37
|
+
useEffect(() => subscribeToAnyTelemetryChange(load), [load]);
|
|
38
|
+
return {
|
|
39
|
+
summary,
|
|
40
|
+
error,
|
|
41
|
+
loading: summary === void 0 && error === void 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { useTelemetrySummary };
|
|
46
|
+
//# sourceMappingURL=useTelemetrySummary.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useTelemetrySummary.esm.js","sources":["../../src/hooks/useTelemetrySummary.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport type { TelemetrySummary } from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../api/DevAiHubResourceClient';\nimport { subscribeToAnyTelemetryChange } from './useTelemetryCounts';\n\n/**\n * Catalog-wide lifetime telemetry totals (#83 backoffice overview, and the\n * usage/installs/views sort modes from issue #102), fetched as a single\n * aggregate rather than one request per resource.\n *\n * Deliberately mirrors `useResources`: a whole admin section — and the\n * browse page's sort order — is built on this data, so a failed fetch is\n * surfaced as a real error state rather than swallowed. That is the opposite\n * choice from `useTelemetryCounts`, whose per-card badge is small enough to\n * just go quiet on failure — there is nothing else on this section for a\n * lost fetch to hide behind.\n *\n * Re-fetches whenever any resource's telemetry changes\n * (`subscribeToAnyTelemetryChange`) — otherwise a view/install/copy/download\n * recorded from the card or the detail drawer would move that resource's own\n * badge live (`useTelemetryCounts` already does that) while this shared\n * summary, and therefore the sort-by-usage/installs/views order derived from\n * it, stayed frozen at whatever it was on page load until a full reload.\n */\nexport function useTelemetrySummary() {\n const api = useApi(devAiHubResourceApiRef);\n const cancelInFlightRef = useRef<() => void>(() => {});\n const [summary, setSummary] = useState<TelemetrySummary | undefined>(\n undefined,\n );\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const load = useCallback(() => {\n cancelInFlightRef.current();\n let cancelled = false;\n const cancel = () => {\n cancelled = true;\n };\n cancelInFlightRef.current = cancel;\n\n api\n .getTelemetrySummary()\n .then(result => {\n if (!cancelled) {\n setSummary(result);\n setError(undefined);\n }\n })\n .catch(e => {\n if (!cancelled) {\n setError(e instanceof Error ? e : new Error(String(e)));\n }\n });\n return cancel;\n }, [api]);\n\n useEffect(() => {\n load();\n return () => cancelInFlightRef.current();\n }, [load]);\n\n useEffect(() => subscribeToAnyTelemetryChange(load), [load]);\n\n return {\n summary,\n error,\n loading: summary === undefined && error === undefined,\n };\n}\n"],"names":[],"mappings":";;;;;AAyBO,SAAS,mBAAA,GAAsB;AACpC,EAAA,MAAM,GAAA,GAAM,OAAO,sBAAsB,CAAA;AACzC,EAAA,MAAM,iBAAA,GAAoB,OAAmB,MAAM;AAAA,EAAC,CAAC,CAAA;AACrD,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAI,QAAA;AAAA,IAC5B;AAAA,GACF;AACA,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAI,SAA4B,MAAS,CAAA;AAE/D,EAAA,MAAM,IAAA,GAAO,YAAY,MAAM;AAC7B,IAAA,iBAAA,CAAkB,OAAA,EAAQ;AAC1B,IAAA,IAAI,SAAA,GAAY,KAAA;AAChB,IAAA,MAAM,SAAS,MAAM;AACnB,MAAA,SAAA,GAAY,IAAA;AAAA,IACd,CAAA;AACA,IAAA,iBAAA,CAAkB,OAAA,GAAU,MAAA;AAE5B,IAAA,GAAA,CACG,mBAAA,EAAoB,CACpB,IAAA,CAAK,CAAA,MAAA,KAAU;AACd,MAAA,IAAI,CAAC,SAAA,EAAW;AACd,QAAA,UAAA,CAAW,MAAM,CAAA;AACjB,QAAA,QAAA,CAAS,MAAS,CAAA;AAAA,MACpB;AAAA,IACF,CAAC,CAAA,CACA,KAAA,CAAM,CAAA,CAAA,KAAK;AACV,MAAA,IAAI,CAAC,SAAA,EAAW;AACd,QAAA,QAAA,CAAS,CAAA,YAAa,QAAQ,CAAA,GAAI,IAAI,MAAM,MAAA,CAAO,CAAC,CAAC,CAAC,CAAA;AAAA,MACxD;AAAA,IACF,CAAC,CAAA;AACH,IAAA,OAAO,MAAA;AAAA,EACT,CAAA,EAAG,CAAC,GAAG,CAAC,CAAA;AAER,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAA,EAAK;AACL,IAAA,OAAO,MAAM,kBAAkB,OAAA,EAAQ;AAAA,EACzC,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAET,EAAA,SAAA,CAAU,MAAM,6BAAA,CAA8B,IAAI,CAAA,EAAG,CAAC,IAAI,CAAC,CAAA;AAE3D,EAAA,OAAO;AAAA,IACL,OAAA;AAAA,IACA,KAAA;AAAA,IACA,OAAA,EAAS,OAAA,KAAY,MAAA,IAAa,KAAA,KAAU;AAAA,GAC9C;AACF;;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as _backstage_frontend_plugin_api from '@backstage/frontend-plugin-api'
|
|
|
3
3
|
|
|
4
4
|
declare const devAiHubPlugin: _backstage_frontend_plugin_api.OverridableFrontendPlugin<{
|
|
5
5
|
root: _backstage_frontend_plugin_api.RouteRef<undefined>;
|
|
6
|
+
backoffice: _backstage_frontend_plugin_api.RouteRef<undefined>;
|
|
6
7
|
}, {}, {
|
|
7
8
|
"page:dev-ai-hub": _backstage_frontend_plugin_api.OverridableExtensionDefinition<{
|
|
8
9
|
kind: "page";
|
|
@@ -47,5 +48,11 @@ declare const devAiHubPlugin: _backstage_frontend_plugin_api.OverridableFrontend
|
|
|
47
48
|
}>;
|
|
48
49
|
|
|
49
50
|
declare const rootRouteRef: _backstage_frontend_plugin_api.RouteRef<undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* The backoffice surface (#83). A separate route from the browse page so the
|
|
53
|
+
* whole surface — not just its contents — can be withheld from users without
|
|
54
|
+
* `dev-ai-hub.backoffice.read` (ADR-0016).
|
|
55
|
+
*/
|
|
56
|
+
declare const backofficeRouteRef: _backstage_frontend_plugin_api.RouteRef<undefined>;
|
|
50
57
|
|
|
51
|
-
export { devAiHubPlugin, rootRouteRef };
|
|
58
|
+
export { backofficeRouteRef, devAiHubPlugin, rootRouteRef };
|
package/dist/index.esm.js
CHANGED