diff --git a/.changeset/migrate-portal-to-nfs.md b/.changeset/migrate-portal-to-nfs.md new file mode 100644 index 000000000..20d67c794 --- /dev/null +++ b/.changeset/migrate-portal-to-nfs.md @@ -0,0 +1,18 @@ +--- +'@openchoreo/backstage-plugin': patch +'@openchoreo/backstage-plugin-openchoreo-ci': patch +'@openchoreo/backstage-plugin-openchoreo-observability': patch +'@openchoreo/backstage-plugin-openchoreo-workflows': patch +'@openchoreo/backstage-plugin-platform-engineer-core': patch +'@openchoreo/backstage-plugin-react': patch +--- + +Add an `/alpha` entry point that exposes each plugin as a `createFrontendPlugin` for use with Backstage's New Frontend System (NFS). The default entry continues to export the legacy `createPlugin` instance so existing host apps keep working unchanged; adopters on NFS can now import `from '@openchoreo/backstage-plugin-/alpha'` and include the plugin directly in `createApp({ features: [...] })`. + +The `/alpha` exports register each plugin's API factories (e.g. `openChoreoCiClientApiRef`, `genericWorkflowsClientApiRef`, the three observability backend clients, `openChoreoClientApiRef`) and one top-level page where applicable (`platform-engineer-core`'s dashboard view, `openchoreo-workflows`' generic workflows page, `openchoreo-ci`'s workflows entity tab). + +Entity tabs and overview cards that previously lived in the host's `EntityPage.tsx` now ride through each plugin's `/alpha` export as `EntityContentBlueprint` and `EntityCardBlueprint` extensions, with the right kind filters. Adopters on `/alpha` get the full entity-page contributions automatically: the OpenChoreo CI plugin contributes the Build tab (scoped to `kind:component`); the observability plugin contributes the 10 component- and system-page tabs (Logs, Events, Metrics, Alerts, Wirelogs, Traces, Incidents, RCA Reports, Cost Analysis) plus a registry API for host-injected log-row action renderers; the OpenChoreo plugin contributes the Deploy tab, the system Cell Diagram tab, the shared Resource Definition tab, and 30+ overview cards spanning every OpenChoreo platform kind (Environment, DataPlane, WorkflowPlane, ObservabilityPlane, DeploymentPipeline, the ComponentType / ResourceType / TraitType families, and the Workflow family); the generic-workflows plugin contributes the Runs tab on `Workflow` and `ClusterWorkflow` entities of type `Generic`. The react plugin exposes a new `FeatureGatedContent` component so plugin authors can gate routable extensions on the OpenChoreo feature flags without rolling their own empty-state wrapper. + +Custom catalog-graph relations, entity-presentation kind icons, and the scaffolder form-decorator override are now actually applied at runtime — the original migration registered them but they were silently overwritten by upstream defaults at startup. The form-decorator override also stops dropping decorators contributed by other plugins. + +Adopters still on the default (legacy) export are unaffected. This addresses the body of [openchoreo/openchoreo#3568](https://github.com/openchoreo/openchoreo/issues/3568) — adopters can drop `--legacy` from the `@backstage/create-app` step when installing the plugin suite into an existing Backstage host. diff --git a/packages/app/package.json b/packages/app/package.json index 2dccef85e..4683c09f2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,10 +26,16 @@ "@backstage/cli": "^0.36.2", "@backstage/config": "^1.3.8", "@backstage/core-app-api": "^1.20.1", + "@backstage/core-compat-api": "^0.5.11", "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-app-api": "^0.16.3", + "@backstage/frontend-defaults": "^0.5.2", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/integration-react": "^1.2.18", "@backstage/plugin-api-docs": "^0.14.1", + "@backstage/plugin-app": "^0.4.6", + "@backstage/plugin-app-react": "^0.2.3", "@backstage/plugin-catalog": "^2.0.5", "@backstage/plugin-catalog-common": "^1.1.10", "@backstage/plugin-catalog-graph": "^0.6.4", @@ -83,6 +89,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.11.3", + "@backstage/frontend-test-utils": "^0.6.0", "@backstage/test-utils": "^1.7.18", "@playwright/test": "1.56.0", "@testing-library/dom": "9.3.4", diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index b6ca21d42..1ab4a1a63 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -1,5 +1,5 @@ import { render, waitFor } from '@testing-library/react'; -import App from './App'; +import app from './App'; describe('App', () => { it('should render', async () => { @@ -19,7 +19,7 @@ describe('App', () => { ] as any, }; - const rendered = render(); + const rendered = render(app); await waitFor(() => { expect(rendered.baseElement).toBeInTheDocument(); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index e3302cd8f..eebae1c56 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,51 +1,10 @@ import { Route } from 'react-router-dom'; -import { apiDocsPlugin } from '@backstage/plugin-api-docs'; -import { - CatalogEntityPage, - CatalogIndexPage, - catalogPlugin, -} from '@backstage/plugin-catalog'; +import { catalogPlugin } from '@backstage/plugin-catalog'; import { CatalogImportPage, catalogImportPlugin, } from '@backstage/plugin-catalog-import'; -import { catalogImportTranslationRef } from '@backstage/plugin-catalog-import/alpha'; -import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; -import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder-react'; -import { ComponentNamePickerFieldExtension } from './scaffolder/ComponentNamePicker'; -import { ResourceNamePickerFieldExtension } from './scaffolder/ResourceNamePicker'; -import { BuildTemplatePickerFieldExtension } from './scaffolder/BuildTemplatePicker'; -import { BuildTemplateParametersFieldExtension } from './scaffolder/BuildTemplateParameters'; -import { BuildWorkflowPickerFieldExtension } from './scaffolder/BuildWorkflowPicker'; -import { BuildWorkflowParametersFieldExtension } from './scaffolder/BuildWorkflowParameters'; -import { TraitsFieldExtension } from './scaffolder/TraitsField'; -import { SwitchFieldExtension } from './scaffolder/SwitchField'; -import { AdvancedConfigurationFieldExtension } from './scaffolder/AdvancedConfigurationField'; -import { DeploymentSourcePickerFieldExtension } from './scaffolder/DeploymentSourcePicker'; -import { BuildAndDeployFieldExtension } from './scaffolder/BuildAndDeployField'; -import { ContainerImageFieldExtension } from './scaffolder/ContainerImageField'; -import { ComponentTypeYamlEditorFieldExtension } from './scaffolder/ComponentTypeYamlEditor'; -import { TraitYamlEditorFieldExtension } from './scaffolder/TraitYamlEditor'; -import { ClusterComponentTypeYamlEditorFieldExtension } from './scaffolder/ClusterComponentTypeYamlEditor'; -import { ClusterResourceTypeYamlEditorFieldExtension } from './scaffolder/ClusterResourceTypeYamlEditor'; -import { ResourceTypeYamlEditorFieldExtension } from './scaffolder/ResourceTypeYamlEditor'; -import { ResourceParametersFieldExtension } from './scaffolder/ResourceParametersField'; -import { ClusterTraitYamlEditorFieldExtension } from './scaffolder/ClusterTraitYamlEditor'; -import { ComponentWorkflowYamlEditorFieldExtension } from './scaffolder/ComponentWorkflowYamlEditor'; -import { ClusterWorkflowYamlEditorFieldExtension } from './scaffolder/ClusterWorkflowYamlEditor'; -import { GitSourceFieldExtension } from './scaffolder/GitSourceField'; -import { ProjectNamespaceFieldExtension } from './scaffolder/ProjectNamespaceField'; -import { NamespaceEntityPickerFieldExtension } from './scaffolder/NamespaceEntityPicker'; -import { DeploymentPipelinePickerFieldExtension } from './scaffolder/DeploymentPipelinePicker'; -import { EnvironmentFormWithYamlFieldExtension } from './scaffolder/EnvironmentFormWithYaml'; -import { DeploymentPipelineFormWithYamlFieldExtension } from './scaffolder/DeploymentPipelineFormWithYaml'; -import { WorkloadDetailsFieldExtension } from './scaffolder/WorkloadDetailsField'; -import { CustomTemplateListPage } from './components/scaffolder/CustomTemplateListPage'; -import { CustomReviewStep } from './scaffolder/CustomReviewState'; -import { ScaffolderPreselectionProvider } from './scaffolder/ScaffolderPreselectionContext'; -import { ScaffolderLayout } from './scaffolder/ScaffolderLayout'; -import { orgPlugin } from '@backstage/plugin-org'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { SearchPage } from '@backstage/plugin-search'; import { TechDocsIndexPage, @@ -54,9 +13,7 @@ import { } from '@backstage/plugin-techdocs'; import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; -import { apis, openChoreoAuthApiRef } from './apis'; -import { entityPage } from './components/catalog/EntityPage'; -import { CustomCatalogPage } from './components/catalog/CustomCatalogPage'; +import { apis } from './apis'; import { CustomApiExplorerPage } from './components/catalog/CustomApiExplorerPage'; import { searchPage } from './components/search/SearchPage'; import { Root } from './components/Root'; @@ -64,22 +21,53 @@ import { HomePage } from './components/Home'; import { CustomGraphNode } from '@openchoreo/backstage-plugin-react'; import { PlatformOverviewPage } from './components/platformOverview'; +import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; +import { createApp } from '@backstage/frontend-defaults'; import { - AlertDisplay, - OAuthRequestDialog, - SignInPage, -} from '@backstage/core-components'; -import { createApp } from '@backstage/app-defaults'; + convertLegacyAppOptions, + convertLegacyAppRoot, +} from '@backstage/core-compat-api'; import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; + +// NFS plugin features (created in Step 2 — each plugin's `/alpha` exports a +// `createFrontendPlugin` instance). These replace the API factory entries +// that previously lived in `apis.ts`. +import openchoreoPluginAlpha from '@openchoreo/backstage-plugin/alpha'; +import openchoreoCiPluginAlpha from '@openchoreo/backstage-plugin-openchoreo-ci/alpha'; +import openchoreoObservabilityPluginAlpha from '@openchoreo/backstage-plugin-openchoreo-observability/alpha'; +import openchoreoWorkflowsPluginAlpha from '@openchoreo/backstage-plugin-openchoreo-workflows/alpha'; +import platformEngineerCorePluginAlpha from '@openchoreo/backstage-plugin-platform-engineer-core/alpha'; + +// Upstream NFS plugin features with our overrides: +// - catalog graph default API replaced to include OpenChoreo custom relations +// - catalog entity-presentation default API replaced to add custom kind icons +// - scaffolder `page:scaffolder` disabled (our legacy wins) +// and form-decorators API replaced to inject the openChoreoTokenDecorator +// - customTranslationsModule reinstates the catalog-import header overrides +// that previously rode via createApp.__experimentalTranslations +import { + catalogGraphPluginAlpha, + catalogPluginAlpha, + customAppModule, + scaffolderPluginAlpha as upstreamScaffolderPluginAlpha, +} from './apis/customOverrides'; + +// catalog-import NFS plugin — registered so the `/catalog-import` route ref +// resolves under NFS. Our legacy `` +// mount in `` provides the actual page rendering. +import catalogImportPluginAlpha from '@backstage/plugin-catalog-import/alpha'; +// api-docs and kubernetes NFS plugins — registered so that `apiDocsConfigRef`, +// `kubernetesApiRef`, etc. are present in the api holder. The host owns the +// `/api-docs` route (CustomApiExplorerPage) and the Kubernetes entity tab +// reuses upstream `EntityKubernetesContent`; without these features the apis +// they depend on are absent and the tabs throw `NotImplementedError`. +import apiDocsPluginAlpha from '@backstage/plugin-api-docs/alpha'; +import kubernetesPluginAlpha from '@backstage/plugin-kubernetes/alpha'; import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; import { appThemes } from './themes'; -import CloudIcon from '@material-ui/icons/Cloud'; -import DnsIcon from '@material-ui/icons/Dns'; -import AccountTreeIcon from '@material-ui/icons/AccountTree'; -import VisibilityIcon from '@material-ui/icons/Visibility'; -import BuildIcon from '@material-ui/icons/Build'; +import { LEGACY_KIND_ICONS } from './kindIcons'; import { AccessControlContent, SecretsContent, @@ -89,129 +77,26 @@ import { SettingsLayout, UserSettingsGeneral, } from '@backstage/plugin-user-settings'; -import CategoryIcon from '@material-ui/icons/Category'; -import LayersIcon from '@material-ui/icons/Layers'; -import StorageIcon from '@material-ui/icons/Storage'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; -import SettingsApplicationsIcon from '@material-ui/icons/SettingsApplications'; import { VisitListener } from '@backstage/plugin-home'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; -import { DependencyGraphZoomOverrides } from './components/graph/DependencyGraphZoomOverrides'; - -/** - * Dynamic SignInPage that switches between OAuth and Guest mode - * based on openchoreo.features.auth.enabled configuration. - * - * When auth is enabled (default): Uses OpenChoreo IDP OAuth flow - * When auth is disabled: Auto-signs in as guest user using Backstage's built-in guest provider - */ -function DynamicSignInPage(props: any) { - const configApi = useApi(configApiRef); - - // Check if auth feature is enabled (defaults to true) - const authEnabled = - configApi.getOptionalBoolean('openchoreo.features.auth.enabled') ?? true; - if (!authEnabled) { - // Guest mode: use Backstage's built-in guest provider - // This uses ProxiedSignInIdentity with the backend guest module - // and falls back to GuestUserIdentity (legacy) if not available - return ; - } - - // Default: OpenChoreo Auth (works with any OIDC-compliant IDP). - // The sign-in page is always shown with a login button. The OAuth2 provider - // handles popup vs. redirect based on the enableExperimentalRedirectFlow config. - return ( - - ); -} - -const catalogImportTranslations = createTranslationMessages({ - ref: catalogImportTranslationRef, - full: false, - messages: { - 'defaultImportPage.headerTitle': 'Register an existing catalog entity', - 'defaultImportPage.contentHeaderTitle': - 'Start tracking your entity in {{appTitle}}', - 'defaultImportPage.supportTitle': - 'Start tracking your entity in {{appTitle}} by adding it to the software catalog.', - 'importInfoCard.title': 'Register an existing catalog entity', - 'stepInitAnalyzeUrl.urlHelperText': - 'Enter the full path to your entity file to start tracking', - 'stepFinishImportLocation.locations.viewButtonText': 'View Entity', - }, -}); - -const app = createApp({ +const legacyAppOptions = convertLegacyAppOptions({ apis, - icons: { - 'kind:environment': CloudIcon, - 'kind:dataplane': DnsIcon, - 'kind:clusterdataplane': DnsIcon, - 'kind:deploymentpipeline': AccountTreeIcon, - 'kind:observabilityplane': VisibilityIcon, - 'kind:clusterobservabilityplane': VisibilityIcon, - 'kind:workflowplane': BuildIcon, - 'kind:clusterworkflowplane': BuildIcon, - 'kind:componenttype': CategoryIcon, - 'kind:clustercomponenttype': CategoryIcon, - 'kind:resourcetype': LayersIcon, - 'kind:clusterresourcetype': LayersIcon, - 'kind:resource': StorageIcon, - 'kind:traittype': ExtensionIcon, - 'kind:clustertraittype': ExtensionIcon, - 'kind:workflow': PlayCircleOutlineIcon, - 'kind:clusterworkflow': PlayCircleOutlineIcon, - 'kind:componentworkflow': SettingsApplicationsIcon, - }, - bindRoutes({ bind }) { - bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.root, - viewTechDoc: techdocsPlugin.routes.docRoot, - createFromTemplate: scaffolderPlugin.routes.selectedTemplate, - }); - bind(apiDocsPlugin.externalRoutes, { - registerApi: catalogImportPlugin.routes.importPage, - }); - bind(scaffolderPlugin.externalRoutes, { - registerComponent: catalogImportPlugin.routes.importPage, - viewTechDoc: techdocsPlugin.routes.docRoot, - }); - bind(orgPlugin.externalRoutes, { - catalogIndex: catalogPlugin.routes.catalogIndex, - }); - }, - components: { - SignInPage: DynamicSignInPage, - }, + icons: LEGACY_KIND_ICONS, themes: appThemes, - __experimentalTranslations: { - resources: [catalogImportTranslations], - }, }); const routes = ( } /> - }> - - - } - > - {entityPage} - + {/* + `/catalog` is owned by the NFS `page:catalog` extension and + `/catalog/:namespace/:kind/:name` by `page:catalog/entity` — see + customOverrides.tsx, which overrides each loader to render the + host's `` and the legacy `entityPage` JSX + respectively. The legacy `` mount used to + live here but double-rendered the catalog header under the NFS + compat shim. + */} } /> - - - - - - } - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {/* + `/create` is owned by the NFS `page:scaffolder` extension — see + customOverrides.tsx, which overrides its loader to render + `` (the host's `` with + the 27 field-extension children and `CustomTemplateListPage` / + `CustomReviewStep` components). The legacy `` + mount used to live here but double-rendered the scaffolder header + under the NFS compat shim. + */} } /> ); -export default app.createRoot( +const legacyRoot = convertLegacyAppRoot( <> - {routes} , ); + +const app = createApp({ + features: [ + // `...legacyRoot` re-emits each legacy plugin's `apis: [...]` array as + // ApiBlueprint extensions under the legacy plugin's own pluginId + // (collectLegacyRoutes). The NFS api-factory registry resolves + // same-pluginId factories last-write-wins, so the override features + // below MUST come after `...legacyRoot` to win the contest. Otherwise + // our custom catalog-graph relations, entity-presentation kind icons, + // and scaffolder form-decorator override get silently overwritten by + // upstream defaults at startup. + legacyAppOptions, + ...legacyRoot, + customAppModule, + upstreamScaffolderPluginAlpha, + catalogGraphPluginAlpha, + catalogPluginAlpha, + catalogImportPluginAlpha, + apiDocsPluginAlpha, + kubernetesPluginAlpha, + openchoreoPluginAlpha, + openchoreoCiPluginAlpha, + openchoreoObservabilityPluginAlpha, + openchoreoWorkflowsPluginAlpha, + platformEngineerCorePluginAlpha, + ], + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + viewTechDoc: techdocsPlugin.routes.docRoot, + createFromTemplate: scaffolderPlugin.routes.selectedTemplate, + }); + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + viewTechDoc: techdocsPlugin.routes.docRoot, + }); + }, +}); + +export default app.createRoot(); diff --git a/packages/app/src/apis.test.ts b/packages/app/src/apis.test.ts index 480200d82..00cc6911b 100644 --- a/packages/app/src/apis.test.ts +++ b/packages/app/src/apis.test.ts @@ -9,6 +9,12 @@ * * We only assert "factory returned an instance" — full client behavior * is covered by each plugin's own tests. + * + * Under NFS, `openchoreo-ci`, `openchoreo-workflows`, and the + * `catalog-graph` override own their API factories via `ApiBlueprint` + * inside their `/alpha` plugins / `customOverrides.tsx`, so they are + * NOT in this app-scoped `apis` array. Their factory bodies are covered + * by the plugins' own tests. */ import { AnyApiFactory, @@ -18,21 +24,13 @@ import { import { permissionApiRef } from '@backstage/plugin-permission-react'; import { visitsApiRef } from '@backstage/plugin-home'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { catalogGraphApiRef } from '@backstage/plugin-catalog-graph'; -import { - openChoreoCiClientApiRef, - OpenChoreoCiClient, -} from '@openchoreo/backstage-plugin-openchoreo-ci'; -import { - genericWorkflowsClientApiRef, - GenericWorkflowsClient, -} from '@openchoreo/backstage-plugin-openchoreo-workflows'; import { perchAgentApiRef, PerchAgentClient, } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; -import { apis, openChoreoAuthApiRef } from './apis'; +import { apis } from './apis'; +import { openChoreoAuthApiRef } from './apis/authRefs'; // Minimal stubs — none of the factories under test inspect dep state at // construction time beyond holding the reference. @@ -72,32 +70,12 @@ describe('apis registry', () => { openChoreoAuthApiRef, visitsApiRef, storageApiRef, - openChoreoCiClientApiRef, - genericWorkflowsClientApiRef, perchAgentApiRef, ]) { expect(ids).toContain(ref.id); } }); - it('builds the OpenChoreoCiClient via its factory', () => { - const f = findFactory(apis, openChoreoCiClientApiRef); - const instance = invoke(f, { - discoveryApi: stubDiscovery, - fetchApi: stubFetch, - }); - expect(instance).toBeInstanceOf(OpenChoreoCiClient); - }); - - it('builds the GenericWorkflowsClient via its factory', () => { - const f = findFactory(apis, genericWorkflowsClientApiRef); - const instance = invoke(f, { - discoveryApi: stubDiscovery, - fetchApi: stubFetch, - }); - expect(instance).toBeInstanceOf(GenericWorkflowsClient); - }); - it('builds the PerchAgentClient via its factory', () => { const f = findFactory(apis, perchAgentApiRef); const instance = invoke(f, { @@ -115,10 +93,4 @@ describe('apis registry', () => { }); expect(instance).toBeDefined(); }); - - it('builds the catalog graph api with custom OpenChoreo relations', () => { - const f = findFactory(apis, catalogGraphApiRef); - const instance = invoke(f, {}); - expect(instance).toBeDefined(); - }); }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 1e2d6807a..3e504c057 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -25,21 +25,7 @@ import { UserSettingsStorage } from '@backstage/plugin-user-settings'; import { permissionApiRef } from '@backstage/plugin-permission-react'; import { OpenChoreoFetchApi } from './apis/OpenChoreoFetchApi'; import { OpenChoreoPermissionApi } from './apis/OpenChoreoPermissionApi'; -import { - formDecoratorsApiRef, - DefaultScaffolderFormDecoratorsApi, -} from '@backstage/plugin-scaffolder/alpha'; -import { openChoreoTokenDecorator } from './scaffolder/openChoreoTokenDecorator'; -// Import from separate file to avoid circular dependency with form decorators import { openChoreoAuthApiRef } from './apis/authRefs'; -import { - openChoreoCiClientApiRef, - OpenChoreoCiClient, -} from '@openchoreo/backstage-plugin-openchoreo-ci'; -import { - genericWorkflowsClientApiRef, - GenericWorkflowsClient, -} from '@openchoreo/backstage-plugin-openchoreo-workflows'; // NOTE: ``perchAgentApiRef`` is also declared on // ``openchoreoPerchPlugin.apis`` in plugins/openchoreo-portal-assistant/src/plugin.ts. // That declaration is NOT picked up by the app at runtime because the plugin @@ -52,47 +38,6 @@ import { perchAgentApiRef, PerchAgentClient, } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; -import { - catalogApiRef, - entityPresentationApiRef, -} from '@backstage/plugin-catalog-react'; -import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; -import { - catalogGraphApiRef, - DefaultCatalogGraphApi, - ALL_RELATIONS, - ALL_RELATION_PAIRS, -} from '@backstage/plugin-catalog-graph'; -import { - RELATION_DEPLOYS_TO, - RELATION_DEPLOYED_BY, - RELATION_USES_PIPELINE, - RELATION_PIPELINE_USED_BY, - RELATION_HOSTED_ON, - RELATION_HOSTS, - RELATION_OBSERVED_BY, - RELATION_OBSERVES, - RELATION_INSTANCE_OF, - RELATION_HAS_INSTANCE, - RELATION_USES_WORKFLOW, - RELATION_WORKFLOW_USED_BY, - RELATION_BUILDS_ON, - RELATION_BUILDS, -} from '@openchoreo/backstage-plugin-common'; -import CloudIcon from '@material-ui/icons/Cloud'; -import DnsIcon from '@material-ui/icons/Dns'; -import AccountTreeIcon from '@material-ui/icons/AccountTree'; -import VisibilityIcon from '@material-ui/icons/Visibility'; -import BuildIcon from '@material-ui/icons/Build'; -import CategoryIcon from '@material-ui/icons/Category'; -import LayersIcon from '@material-ui/icons/Layers'; -import StorageIcon from '@material-ui/icons/Storage'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; -import SettingsApplicationsIcon from '@material-ui/icons/SettingsApplications'; - -// Re-export for use by App.tsx and other components -export { openChoreoAuthApiRef }; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -179,78 +124,6 @@ export const apis: AnyApiFactory[] = [ factory: deps => UserSettingsStorage.create(deps), }), - // Form decorators for scaffolder - injects user's OpenChoreo token as a secret - // This enables user-based authorization in scaffolder actions - createApiFactory({ - api: formDecoratorsApiRef, - deps: {}, - factory: () => - DefaultScaffolderFormDecoratorsApi.create({ - decorators: [openChoreoTokenDecorator], - }), - }), - - // OpenChoreo CI client - provides API for workflow/build operations - createApiFactory({ - api: openChoreoCiClientApiRef, - deps: { - discoveryApi: discoveryApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ discoveryApi, fetchApi }) => - new OpenChoreoCiClient(discoveryApi, fetchApi), - }), - - // Catalog graph API with custom OpenChoreo relations - // Without this, custom relations (deploysTo, hostedOn, instanceOf, etc.) - // won't appear in entity Relations cards or the catalog graph - createApiFactory({ - api: catalogGraphApiRef, - deps: {}, - factory: () => - new DefaultCatalogGraphApi({ - knownRelations: [ - ...ALL_RELATIONS, - RELATION_DEPLOYS_TO, - RELATION_DEPLOYED_BY, - RELATION_USES_PIPELINE, - RELATION_PIPELINE_USED_BY, - RELATION_HOSTED_ON, - RELATION_HOSTS, - RELATION_OBSERVED_BY, - RELATION_OBSERVES, - RELATION_INSTANCE_OF, - RELATION_HAS_INSTANCE, - RELATION_USES_WORKFLOW, - RELATION_WORKFLOW_USED_BY, - RELATION_BUILDS_ON, - RELATION_BUILDS, - ], - knownRelationPairs: [ - ...ALL_RELATION_PAIRS, - [RELATION_DEPLOYS_TO, RELATION_DEPLOYED_BY], - [RELATION_USES_PIPELINE, RELATION_PIPELINE_USED_BY], - [RELATION_HOSTED_ON, RELATION_HOSTS], - [RELATION_OBSERVED_BY, RELATION_OBSERVES], - [RELATION_INSTANCE_OF, RELATION_HAS_INSTANCE], - [RELATION_USES_WORKFLOW, RELATION_WORKFLOW_USED_BY], - [RELATION_BUILDS_ON, RELATION_BUILDS], - ], - defaultRelationTypes: { exclude: [] }, - }), - }), - - // Generic Workflows client - provides API for org-level workflow operations - createApiFactory({ - api: genericWorkflowsClientApiRef, - deps: { - discoveryApi: discoveryApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ discoveryApi, fetchApi }) => - new GenericWorkflowsClient(discoveryApi, fetchApi), - }), - // Assistant Agent client (Perch). Mirrors the registration on // openchoreoPerchPlugin.apis — see the import-site comment for why // both exist. @@ -263,34 +136,4 @@ export const apis: AnyApiFactory[] = [ factory: ({ discoveryApi, fetchApi }) => new PerchAgentClient({ discoveryApi, fetchApi }), }), - - // Custom EntityPresentationApi with icons for custom entity kinds - // This enables icons for Environment, DataPlane, and DeploymentPipeline in the catalog graph - createApiFactory({ - api: entityPresentationApiRef, - deps: { catalogApi: catalogApiRef }, - factory: ({ catalogApi }) => - DefaultEntityPresentationApi.create({ - catalogApi, - kindIcons: { - environment: CloudIcon, - dataplane: DnsIcon, - clusterdataplane: DnsIcon, - deploymentpipeline: AccountTreeIcon, - observabilityplane: VisibilityIcon, - clusterobservabilityplane: VisibilityIcon, - workflowplane: BuildIcon, - clusterworkflowplane: BuildIcon, - componenttype: CategoryIcon, - clustercomponenttype: CategoryIcon, - resourcetype: LayersIcon, - clusterresourcetype: LayersIcon, - resource: StorageIcon, - traittype: ExtensionIcon, - clustertraittype: ExtensionIcon, - workflow: PlayCircleOutlineIcon, - componentworkflow: SettingsApplicationsIcon, - }, - }), - }), ]; diff --git a/packages/app/src/apis/customOverrides.test.tsx b/packages/app/src/apis/customOverrides.test.tsx new file mode 100644 index 000000000..dfe93a8ea --- /dev/null +++ b/packages/app/src/apis/customOverrides.test.tsx @@ -0,0 +1,48 @@ +import { + catalogGraphPluginAlpha, + catalogPluginAlpha, + customAppModule, + scaffolderPluginAlpha, +} from './customOverrides'; + +describe('customOverrides', () => { + it('exports a catalog-graph plugin override', () => { + expect(catalogGraphPluginAlpha).toBeDefined(); + expect((catalogGraphPluginAlpha as any).id).toBe('catalog-graph'); + }); + + it('exports a catalog plugin override', () => { + expect(catalogPluginAlpha).toBeDefined(); + expect((catalogPluginAlpha as any).id).toBe('catalog'); + }); + + it('exports a scaffolder plugin override', () => { + expect(scaffolderPluginAlpha).toBeDefined(); + expect((scaffolderPluginAlpha as any).id).toBe('scaffolder'); + }); + + it('exports the customAppModule frontend module', () => { + expect(customAppModule).toBeDefined(); + // `createFrontendModule({ pluginId: 'app', ... })` produces a frontend + // module bound to the `app` plugin id. + expect( + (customAppModule as any).id ?? (customAppModule as any).pluginId, + ).toBe('app'); + }); + + it('registers extensions on the customAppModule (SignInPage, Translation, LogRowAction)', () => { + const extensions = ((customAppModule as any).extensions ?? []) as Array<{ + id: string; + }>; + expect(Array.isArray(extensions)).toBe(true); + expect(extensions.length).toBeGreaterThan(0); + + // The host registers exactly three extensions on the app module today: + // a SignInPage, a Translation override (catalog-import), and a + // LogRowAction renderer. Overview-slot cards (OpenChoreoAboutCard, + // WorkflowsOrExternalCICard) used to live here but moved back into the + // hand-authored `entityPage` JSX when we restored the custom + // page:catalog/entity override — see customOverrides.tsx for context. + expect(extensions).toHaveLength(3); + }); +}); diff --git a/packages/app/src/apis/customOverrides.tsx b/packages/app/src/apis/customOverrides.tsx new file mode 100644 index 000000000..abb4da6e8 --- /dev/null +++ b/packages/app/src/apis/customOverrides.tsx @@ -0,0 +1,294 @@ +/** + * Step 3c — plugin-scoped overrides for upstream NFS plugin APIs we customize. + * + * Under NFS, registering a custom API factory under our `app` plugin id + * collides with the upstream plugin that already owns that API id + * (API_FACTORY_CONFLICT). Instead, override the existing extension under the + * upstream plugin's own pluginId via `withOverrides({ extensions: [...] })`. + */ + +import catalogGraphPluginAlphaBase from '@backstage/plugin-catalog-graph/alpha'; +import catalogPluginAlphaBase from '@backstage/plugin-catalog/alpha'; +import scaffolderPluginAlphaBase from '@backstage/plugin-scaffolder/alpha'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { createTranslationMessages } from '@backstage/frontend-plugin-api'; +import { + SignInPageBlueprint, + TranslationBlueprint, +} from '@backstage/plugin-app-react'; +import { catalogImportTranslationRef } from '@backstage/plugin-catalog-import/alpha'; +import { + catalogGraphApiRef, + DefaultCatalogGraphApi, + ALL_RELATIONS, + ALL_RELATION_PAIRS, +} from '@backstage/plugin-catalog-graph'; +import { + catalogApiRef, + entityPresentationApiRef, +} from '@backstage/plugin-catalog-react'; +import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; +import { + formDecoratorsApiRef, + DefaultScaffolderFormDecoratorsApi, +} from '@backstage/plugin-scaffolder/alpha'; +import { FormDecoratorBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; +import { + RELATION_DEPLOYS_TO, + RELATION_DEPLOYED_BY, + RELATION_USES_PIPELINE, + RELATION_PIPELINE_USED_BY, + RELATION_HOSTED_ON, + RELATION_HOSTS, + RELATION_OBSERVED_BY, + RELATION_OBSERVES, + RELATION_INSTANCE_OF, + RELATION_HAS_INSTANCE, + RELATION_USES_WORKFLOW, + RELATION_WORKFLOW_USED_BY, + RELATION_BUILDS_ON, + RELATION_BUILDS, +} from '@openchoreo/backstage-plugin-common'; +import { KIND_ICONS } from '../kindIcons'; +import { openChoreoTokenDecorator } from '../scaffolder/openChoreoTokenDecorator'; +import { LogRowActionBlueprint } from '@openchoreo/backstage-plugin-openchoreo-observability/alpha'; +import { InvestigateLogButton } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; + +/** + * Override `catalog-graph`'s default `api:catalog-graph` to include the + * custom OpenChoreo relations (deploysTo, hostedOn, instanceOf, …). Without + * this, custom relations don't render in entity Relations cards or the + * catalog graph. + */ +export const catalogGraphPluginAlpha = + catalogGraphPluginAlphaBase.withOverrides({ + extensions: [ + catalogGraphPluginAlphaBase.getExtension('api:catalog-graph').override({ + params: defineParams => + defineParams({ + api: catalogGraphApiRef, + deps: {}, + factory: () => + new DefaultCatalogGraphApi({ + knownRelations: [ + ...ALL_RELATIONS, + RELATION_DEPLOYS_TO, + RELATION_DEPLOYED_BY, + RELATION_USES_PIPELINE, + RELATION_PIPELINE_USED_BY, + RELATION_HOSTED_ON, + RELATION_HOSTS, + RELATION_OBSERVED_BY, + RELATION_OBSERVES, + RELATION_INSTANCE_OF, + RELATION_HAS_INSTANCE, + RELATION_USES_WORKFLOW, + RELATION_WORKFLOW_USED_BY, + RELATION_BUILDS_ON, + RELATION_BUILDS, + ], + knownRelationPairs: [ + ...ALL_RELATION_PAIRS, + [RELATION_DEPLOYS_TO, RELATION_DEPLOYED_BY], + [RELATION_USES_PIPELINE, RELATION_PIPELINE_USED_BY], + [RELATION_HOSTED_ON, RELATION_HOSTS], + [RELATION_OBSERVED_BY, RELATION_OBSERVES], + [RELATION_INSTANCE_OF, RELATION_HAS_INSTANCE], + [RELATION_USES_WORKFLOW, RELATION_WORKFLOW_USED_BY], + [RELATION_BUILDS_ON, RELATION_BUILDS], + ], + defaultRelationTypes: { exclude: [] }, + }), + }), + }), + ], + }); + +/** + * Override `catalog`'s default `api:catalog/entity-presentation` to provide + * kind icons for OpenChoreo-specific entity kinds (Environment, DataPlane, + * DeploymentPipeline, etc.) in the catalog graph and entity views. + * + * Also overrides `page:catalog` so /catalog renders the host's + * `CustomCatalogPage` (kind-grouped picker + card grid layout) instead of + * upstream's `DefaultCatalogPage`. Before the createApp feature reorder + * landed, the legacy `` mount won; under the + * reorder, upstream's NFS extension wins by default — so we override its + * loader explicitly. + * + * Finally, overrides `page:catalog/entity` so the entity page rides through + * our `OpenChoreoCatalogEntityPage` (which sets up `AsyncEntityProvider` + + * `EntityLayoutWithDelete` wrapping `OpenChoreoEntityLayout` with the + * dropdown-driven `CompactEntityHeader` and styled tab bar). The hand- + * authored per-kind layouts in `entityPage` (Overview Grid, custom + * EntityCatalogGraphCard, FailedBuildSnackbar, etc.) are rendered as + * `` children — `OpenChoreoEntityLayout` accepts the + * same data key, so the legacy JSX slots in unchanged. + * + * NFS-contributed `EntityContentBlueprint`s (in `inputs.contents`) are + * NOT mounted here because every tab the portal needs is already declared + * by `entityPage`. If a future third-party plugin contributes a tab via + * `EntityContentBlueprint`, switch this loader to a + * `factory(originalFactory, { inputs })` form and merge `inputs.contents` + * deduped by path. + */ +export const catalogPluginAlpha = catalogPluginAlphaBase.withOverrides({ + extensions: [ + catalogPluginAlphaBase.getExtension('page:catalog').override({ + params: { + // `noHeader: true` suppresses the NFS `PageLayout`'s built-in title + // bar ("Catalog" link). The host's `CustomCatalogPage` mounts its own + // ``; without this we render two + // page headers, one above the other. + noHeader: true, + loader: () => + import('../components/catalog/CustomCatalogPage').then(m => ( + + )), + }, + }), + catalogPluginAlphaBase + .getExtension('api:catalog/entity-presentation') + .override({ + params: defineParams => + defineParams({ + api: entityPresentationApiRef, + deps: { catalogApi: catalogApiRef }, + factory: ({ catalogApi }) => + DefaultEntityPresentationApi.create({ + catalogApi, + kindIcons: KIND_ICONS, + }), + }), + }), + catalogPluginAlphaBase.getExtension('page:catalog/entity').override({ + params: { + loader: async () => { + const [{ OpenChoreoCatalogEntityPage }, { entityPage }] = + await Promise.all([ + import('../components/catalog/OpenChoreoCatalogEntityPage'), + import('../components/catalog/EntityPage'), + ]); + return ( + + {entityPage} + + ); + }, + }, + }), + ], +}); + +/** + * Override `scaffolder`'s default `page:scaffolder` (disabled — the legacy + * `` mount at `/create` wins) and + * `api:scaffolder/form-decorators` to inject the user's OpenChoreo token as + * a secret for user-based authorization in scaffolder actions. + */ +/** + * App-scoped extensions: + * - SignInPage: lazy-loaded DynamicSignInPage that switches between + * OpenChoreo OIDC and guest mode based on `openchoreo.features.auth.enabled`. + * Replaces the legacy `createApp.components.SignInPage` slot. + * - Translation overrides for catalog-import that previously rode via + * `createApp.__experimentalTranslations`. Customizes the header strings to + * read "Register an existing catalog entity" rather than the upstream + * default "Register Software". + */ +export const customAppModule = createFrontendModule({ + pluginId: 'app', + extensions: [ + SignInPageBlueprint.make({ + params: { + loader: () => + import('../components/DynamicSignInPage').then(m => m.default), + }, + }), + TranslationBlueprint.make({ + name: 'catalog-import-overrides', + params: { + resource: createTranslationMessages({ + ref: catalogImportTranslationRef, + full: false, + messages: { + 'defaultImportPage.headerTitle': + 'Register an existing catalog entity', + 'defaultImportPage.contentHeaderTitle': + 'Start tracking your entity in {{appTitle}}', + 'defaultImportPage.supportTitle': + 'Start tracking your entity in {{appTitle}} by adding it to the software catalog.', + 'importInfoCard.title': 'Register an existing catalog entity', + 'stepInitAnalyzeUrl.urlHelperText': + 'Enter the full path to your entity file to start tracking', + 'stepFinishImportLocation.locations.viewButtonText': 'View Entity', + }, + }), + }, + }), + // Host-injected per-row action renderer for the observability + // runtime-logs tables. Wires the portal-assistant's + // InvestigateLogButton into ObservabilityRuntimeLogs / + // ObservabilityProjectRuntimeLogs without coupling the + // observability plugin to portal-assistant. Mirrors upstream's + // FormDecoratorBlueprint registration pattern. + LogRowActionBlueprint.make({ + name: 'investigate-log', + params: { + renderer: (log, getLogsSnapshot) => ( + + ), + }, + }), + ], +}); + +export const scaffolderPluginAlpha = scaffolderPluginAlphaBase.withOverrides({ + extensions: [ + // Override `page:scaffolder`'s loader to render the host's + // `` composition (CustomTemplateListPage, + // CustomReviewStep, and 27 field extensions). Same reason as the + // `page:catalog` override above: the createApp feature reorder lets + // upstream's NFS scaffolder page win by default, which would drop + // every host customization. + scaffolderPluginAlphaBase.getExtension('page:scaffolder').override({ + params: { + // Same `noHeader: true` reason as the `page:catalog` override above + // — `OpenChoreoScaffolderPage` mounts a `` that + // renders its own page header. + noHeader: true, + loader: () => + import('../components/scaffolder/OpenChoreoScaffolderPage').then( + m => , + ), + }, + }), + // Inject the OpenChoreo IDP-token decorator alongside any other + // FormDecoratorBlueprint extensions plugins may contribute. The + // earlier shape (`params: defineParams => defineParams({ factory: () => create({decorators: [openChoreoTokenDecorator]}) })`) + // discarded `inputs.formDecorators`, silently dropping every other + // plugin's decorator. Using `factory(originalFactory, { inputs })` + // preserves the upstream accumulation and then concats ours. + scaffolderPluginAlphaBase + .getExtension('api:scaffolder/form-decorators') + .override({ + factory(originalFactory, { inputs }) { + const contributed = inputs.formDecorators.map(e => + e.get(FormDecoratorBlueprint.dataRefs.formDecoratorLoader), + ); + return originalFactory({ + params: defineParams => + defineParams({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => + DefaultScaffolderFormDecoratorsApi.create({ + decorators: [openChoreoTokenDecorator, ...contributed], + }), + }), + }); + }, + }), + ], +}); diff --git a/packages/app/src/components/DynamicSignInPage.tsx b/packages/app/src/components/DynamicSignInPage.tsx new file mode 100644 index 000000000..34984e443 --- /dev/null +++ b/packages/app/src/components/DynamicSignInPage.tsx @@ -0,0 +1,41 @@ +import { SignInPage } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import type { SignInPageProps } from '@backstage/plugin-app-react'; +import { openChoreoAuthApiRef } from '../apis/authRefs'; + +/** + * Dynamic SignInPage that switches between OpenChoreo OIDC and guest mode + * based on `openchoreo.features.auth.enabled`. + * + * - `auth.enabled = true` (default): OpenChoreo IDP OAuth via the + * `openChoreoAuthApiRef`. The SignInPage shows a login button. + * - `auth.enabled = false`: Backstage's built-in `guest` provider with + * `auto`, so we auto-sign-in. + * + * Mounted as an NFS `SignInPageBlueprint` extension (see + * `apis/customOverrides.tsx`), replacing the legacy `createApp.components. + * SignInPage` slot. + */ +export function DynamicSignInPage(props: SignInPageProps) { + const configApi = useApi(configApiRef); + const authEnabled = + configApi.getOptionalBoolean('openchoreo.features.auth.enabled') ?? true; + + if (!authEnabled) { + return ; + } + + return ( + + ); +} + +export default DynamicSignInPage; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index c08cbcc23..60a42e529 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -39,6 +39,8 @@ import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import CategoryIcon from '@material-ui/icons/Category'; import BubbleChartIcon from '@material-ui/icons/BubbleChart'; import { AssistantDrawerProvider } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; +import { ScaffolderPreselectionProvider } from '../../scaffolder/ScaffolderPreselectionContext'; +import { DependencyGraphZoomOverrides } from '../graph/DependencyGraphZoomOverrides'; const isMac = typeof navigator !== 'undefined' && @@ -168,75 +170,85 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { useSearchModalStyles(); const a11yClasses = useA11yStyles(); return ( - - - Skip to main content - - - - - -
- } to="/search"> - - - - {({ toggleModal }) => ( - - )} - - - -
-
- - }> - {/* Global nav, not org-specific */} - - - - - - {/* TechDocs disabled until proper production support is implemented */} - {/* */} - - {/* End global nav */} - - {/* Items in this group will be scrollable if they run out of space */} - - - - - } - to="/settings" + + + {/* + Mounted inside (which lives under per + convertLegacyAppRoot's children-recognition rules) so the + component's MutationObserver runs in the routed subtree. The + previous placement as an sibling was silently + dropped by convertLegacyAppRoot during the NFS migration. + */} + + + Skip to main content + + + + + +
+ } to="/search"> + + + + {({ toggleModal }) => ( + + )} + + + +
+
+ + }> + {/* Global nav, not org-specific */} + + + + + + {/* TechDocs disabled until proper production support is implemented */} + {/* */} + + {/* End global nav */} + + {/* Items in this group will be scrollable if they run out of space */} + + + + + } + to="/settings" + > + + + + +
+
- - - - - -
- {children} -
- - + {children} +
+
+
+
); }; diff --git a/packages/app/src/components/catalog/EntityLayoutWithDelete.tsx b/packages/app/src/components/catalog/EntityLayoutWithDelete.tsx index 02fbd8364..85db8bff9 100644 --- a/packages/app/src/components/catalog/EntityLayoutWithDelete.tsx +++ b/packages/app/src/components/catalog/EntityLayoutWithDelete.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from 'react'; import { Box } from '@material-ui/core'; -import { useEntity } from '@backstage/plugin-catalog-react'; +import { useAsyncEntity, useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, Progress } from '@backstage/core-components'; import { VisuallyHidden } from '@openchoreo/backstage-design-system'; import { @@ -59,15 +59,15 @@ interface EntityLayoutWithDeleteProps { } /** - * Wrapper component that adds delete menu functionality to OpenChoreoEntityLayout. - * Children (OpenChoreoEntityLayout.Route elements) are passed through, keeping them - * in static JSX so Backstage can discover routable extensions. - * - * Also checks if the entity exists in OpenChoreo: - * - If not found (404), shows empty state with "Not Found" message - * - If marked for deletion, shows empty state with "Marked for Deletion" message + * Inner content component that does the actual rendering once the entity is + * known to be loaded. Called by the gating `EntityLayoutWithDelete` wrapper + * below — must NEVER be rendered when `useAsyncEntity()` is still loading, + * because `useEntity()` throws on undefined entity and the four custom hooks + * (`useResourceDefinitionPermission`, `useDeleteEntityMenuItems`, + * `useAnnotationEditorMenuItems`, `useEntityExistsCheck`) all access + * `entity.kind` / `entity.metadata` unconditionally. */ -export function EntityLayoutWithDelete({ +function EntityLayoutWithDeleteContent({ children, kindDisplayNames, parentEntityRelations = ['partOf'], @@ -194,3 +194,47 @@ export function EntityLayoutWithDelete({ ); } + +/** + * Gating wrapper. Lives directly under `AsyncEntityProvider` (see + * `OpenChoreoCatalogEntityPage`) and handles the loading / error / missing + * states upstream `EntityLayout` would normally handle. Once the entity is + * loaded, defers to `EntityLayoutWithDeleteContent` for the real rendering. + * + * Necessary because `EntityLayoutWithDeleteContent` calls `useEntity()` and + * several entity-dependent hooks unconditionally — calling them during the + * loading window throws (`useEntity` rejects undefined entity). + */ +export function EntityLayoutWithDelete(props: EntityLayoutWithDeleteProps) { + const { entity, loading, error } = useAsyncEntity(); + + if (loading) { + return ; + } + + if (error) { + return ( + + + + ); + } + + if (!entity) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index a167cabf5..d8f30a9f8 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -139,10 +139,10 @@ import { import { FeatureGate, + FeatureGatedContent, CustomGraphNode, OpenChoreoEntityLayout, } from '@openchoreo/backstage-plugin-react'; -import { FeatureGatedContent } from './FeatureGatedContent'; import { WorkflowsOrExternalCICard } from './WorkflowsOrExternalCICard'; // External CI Platform imports diff --git a/packages/app/src/components/catalog/OpenChoreoCatalogEntityPage.tsx b/packages/app/src/components/catalog/OpenChoreoCatalogEntityPage.tsx new file mode 100644 index 000000000..ec865d702 --- /dev/null +++ b/packages/app/src/components/catalog/OpenChoreoCatalogEntityPage.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useAsyncRetry from 'react-use/esm/useAsyncRetry'; +import type { Entity } from '@backstage/catalog-model'; +import { + errorApiRef, + useApi, + useRouteRefParams, +} from '@backstage/core-plugin-api'; +import { + AsyncEntityProvider, + catalogApiRef, + entityRouteRef, + type EntityLoadingStatus, +} from '@backstage/plugin-catalog-react'; + +/** + * Local copy of upstream's internal `useEntityFromUrl` hook (not part of + * `@backstage/plugin-catalog`'s public surface — see + * `node_modules/@backstage/plugin-catalog/src/components/CatalogEntityPage/useEntityFromUrl.ts`). + * Inlined here so the NFS `page:catalog/entity` override can mount its own + * `AsyncEntityProvider` without relying on a private import. + */ +function useEntityFromUrl(): EntityLoadingStatus { + const { kind, namespace, name } = useRouteRefParams(entityRouteRef); + const navigate = useNavigate(); + const errorApi = useApi(errorApiRef); + const catalogApi = useApi(catalogApiRef); + + const { + value: entity, + error, + loading, + retry: refresh, + } = useAsyncRetry( + () => + catalogApi.getEntityByRef({ kind, namespace, name }) as Promise< + Entity | undefined + >, + [catalogApi, kind, namespace, name], + ); + + useEffect(() => { + if (!name) { + errorApi.post(new Error('No name provided!')); + navigate('/'); + } + }, [errorApi, navigate, error, loading, entity, name]); + + return { entity, loading, error, refresh }; +} + +interface OpenChoreoCatalogEntityPageProps { + /** + * The legacy `entityPage` ``. Each per-kind branch + * (`componentPage`, `dataplanePage`, etc.) already opens with its own + * `` + `` children, + * so we only need to provide the `AsyncEntityProvider` here — wrapping + * the switch in another `EntityLayoutWithDelete` would double-wrap and + * fail `OpenChoreoEntityLayout`'s strict Route-child check. + */ + children: ReactNode; +} + +/** + * Replacement for the legacy `` mount, used by the NFS + * `page:catalog/entity` extension override in `customOverrides.tsx`. Owns + * the `AsyncEntityProvider` so descendant `useAsyncEntity()` / + * `useEntity()` calls in the per-kind layouts work; layout/header/tab + * styling lives inside each per-kind page's own `EntityLayoutWithDelete` → + * `OpenChoreoEntityLayout`. + */ +export function OpenChoreoCatalogEntityPage({ + children, +}: OpenChoreoCatalogEntityPageProps) { + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx b/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx new file mode 100644 index 000000000..49ae12312 --- /dev/null +++ b/packages/app/src/components/scaffolder/OpenChoreoScaffolderPage.tsx @@ -0,0 +1,90 @@ +import { ScaffolderPage } from '@backstage/plugin-scaffolder'; +import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder-react'; +import { ScaffolderLayout } from '../../scaffolder/ScaffolderLayout'; +import { ComponentNamePickerFieldExtension } from '../../scaffolder/ComponentNamePicker'; +import { ResourceNamePickerFieldExtension } from '../../scaffolder/ResourceNamePicker'; +import { BuildTemplatePickerFieldExtension } from '../../scaffolder/BuildTemplatePicker'; +import { BuildTemplateParametersFieldExtension } from '../../scaffolder/BuildTemplateParameters'; +import { BuildWorkflowPickerFieldExtension } from '../../scaffolder/BuildWorkflowPicker'; +import { BuildWorkflowParametersFieldExtension } from '../../scaffolder/BuildWorkflowParameters'; +import { TraitsFieldExtension } from '../../scaffolder/TraitsField'; +import { SwitchFieldExtension } from '../../scaffolder/SwitchField'; +import { AdvancedConfigurationFieldExtension } from '../../scaffolder/AdvancedConfigurationField'; +import { DeploymentSourcePickerFieldExtension } from '../../scaffolder/DeploymentSourcePicker'; +import { BuildAndDeployFieldExtension } from '../../scaffolder/BuildAndDeployField'; +import { ContainerImageFieldExtension } from '../../scaffolder/ContainerImageField'; +import { ComponentTypeYamlEditorFieldExtension } from '../../scaffolder/ComponentTypeYamlEditor'; +import { TraitYamlEditorFieldExtension } from '../../scaffolder/TraitYamlEditor'; +import { ClusterComponentTypeYamlEditorFieldExtension } from '../../scaffolder/ClusterComponentTypeYamlEditor'; +import { ClusterResourceTypeYamlEditorFieldExtension } from '../../scaffolder/ClusterResourceTypeYamlEditor'; +import { ResourceTypeYamlEditorFieldExtension } from '../../scaffolder/ResourceTypeYamlEditor'; +import { ResourceParametersFieldExtension } from '../../scaffolder/ResourceParametersField'; +import { ClusterTraitYamlEditorFieldExtension } from '../../scaffolder/ClusterTraitYamlEditor'; +import { ComponentWorkflowYamlEditorFieldExtension } from '../../scaffolder/ComponentWorkflowYamlEditor'; +import { ClusterWorkflowYamlEditorFieldExtension } from '../../scaffolder/ClusterWorkflowYamlEditor'; +import { GitSourceFieldExtension } from '../../scaffolder/GitSourceField'; +import { ProjectNamespaceFieldExtension } from '../../scaffolder/ProjectNamespaceField'; +import { NamespaceEntityPickerFieldExtension } from '../../scaffolder/NamespaceEntityPicker'; +import { DeploymentPipelinePickerFieldExtension } from '../../scaffolder/DeploymentPipelinePicker'; +import { EnvironmentFormWithYamlFieldExtension } from '../../scaffolder/EnvironmentFormWithYaml'; +import { DeploymentPipelineFormWithYamlFieldExtension } from '../../scaffolder/DeploymentPipelineFormWithYaml'; +import { WorkloadDetailsFieldExtension } from '../../scaffolder/WorkloadDetailsField'; +import { CustomTemplateListPage } from './CustomTemplateListPage'; +import { CustomReviewStep } from '../../scaffolder/CustomReviewState'; + +/** + * Host's scaffolder page composition — `` with the + * OpenChoreo header copy, the CustomTemplateListPage / CustomReviewStep + * component overrides, and all 27 field-extension children. Used by the + * `page:scaffolder` override in customOverrides.tsx so /create renders + * through NFS without losing any of the host customizations the legacy + * `...}>` mount supplied. + */ +export function OpenChoreoScaffolderPage() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/app/src/components/settings/OpenChoreoProviderSettings.tsx b/packages/app/src/components/settings/OpenChoreoProviderSettings.tsx index 5b8652bfe..075c1a406 100644 --- a/packages/app/src/components/settings/OpenChoreoProviderSettings.tsx +++ b/packages/app/src/components/settings/OpenChoreoProviderSettings.tsx @@ -1,6 +1,6 @@ import List from '@material-ui/core/List'; import { ProviderSettingsItem } from '@backstage/plugin-user-settings'; -import { openChoreoAuthApiRef } from '../../apis'; +import { openChoreoAuthApiRef } from '../../apis/authRefs'; import { OpenChoreoIcon } from '@openchoreo/backstage-design-system'; export const OpenChoreoProviderSettings = () => { diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index f6a4258dd..d1a79e00d 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -1,7 +1,7 @@ import '@backstage/cli/asset-types'; import ReactDOM from 'react-dom/client'; -import App from './App'; +import app from './App'; import '@backstage/ui/css/styles.css'; import './buiOverrides.css'; -ReactDOM.createRoot(document.getElementById('root')!).render(); +ReactDOM.createRoot(document.getElementById('root')!).render(app); diff --git a/packages/app/src/kindIcons.ts b/packages/app/src/kindIcons.ts new file mode 100644 index 000000000..5d417b1ab --- /dev/null +++ b/packages/app/src/kindIcons.ts @@ -0,0 +1,44 @@ +import type { IconComponent } from '@backstage/core-plugin-api'; +import CloudIcon from '@material-ui/icons/Cloud'; +import DnsIcon from '@material-ui/icons/Dns'; +import AccountTreeIcon from '@material-ui/icons/AccountTree'; +import VisibilityIcon from '@material-ui/icons/Visibility'; +import BuildIcon from '@material-ui/icons/Build'; +import CategoryIcon from '@material-ui/icons/Category'; +import LayersIcon from '@material-ui/icons/Layers'; +import StorageIcon from '@material-ui/icons/Storage'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; +import SettingsApplicationsIcon from '@material-ui/icons/SettingsApplications'; + +/** + * Single source of truth for OpenChoreo platform kind icons. Both + * `App.tsx` (legacy `convertLegacyAppOptions.icons` shape, `kind:`) + * and `customOverrides.tsx` (NFS `DefaultEntityPresentationApi.kindIcons` + * shape, bare ``) derive their respective keyed shapes from this map. + */ +export const KIND_ICONS: Record = { + environment: CloudIcon, + dataplane: DnsIcon, + clusterdataplane: DnsIcon, + deploymentpipeline: AccountTreeIcon, + observabilityplane: VisibilityIcon, + clusterobservabilityplane: VisibilityIcon, + workflowplane: BuildIcon, + clusterworkflowplane: BuildIcon, + componenttype: CategoryIcon, + clustercomponenttype: CategoryIcon, + resourcetype: LayersIcon, + clusterresourcetype: LayersIcon, + resource: StorageIcon, + traittype: ExtensionIcon, + clustertraittype: ExtensionIcon, + workflow: PlayCircleOutlineIcon, + clusterworkflow: PlayCircleOutlineIcon, + componentworkflow: SettingsApplicationsIcon, +}; + +/** `kind:`-keyed shape consumed by `convertLegacyAppOptions.icons`. */ +export const LEGACY_KIND_ICONS = Object.fromEntries( + Object.entries(KIND_ICONS).map(([k, v]) => [`kind:${k}`, v]), +); diff --git a/packages/app/src/scaffolder/ScaffolderPreselectionContext.tsx b/packages/app/src/scaffolder/ScaffolderPreselectionContext.tsx index 717549360..16f2132d4 100644 --- a/packages/app/src/scaffolder/ScaffolderPreselectionContext.tsx +++ b/packages/app/src/scaffolder/ScaffolderPreselectionContext.tsx @@ -47,16 +47,15 @@ export const ScaffolderPreselectionProvider = ({ string | null >(null); - // Capture params on mount or when URL changes + // Capture params on mount or when URL changes. Reset to null when the + // param disappears so stale state doesn't leak across routes (the + // provider now lives at whole-app scope under Root). Without the + // explicit reset, visiting `?namespace=foo` anywhere in the app would + // seed a preselection that persisted into a later `/create` visit + // with no namespace query. useEffect(() => { - const projectParam = searchParams.get('project'); - if (projectParam) { - setPreselectedProject(projectParam); - } - const namespaceParam = searchParams.get('namespace'); - if (namespaceParam) { - setPreselectedNamespace(namespaceParam); - } + setPreselectedProject(searchParams.get('project')); + setPreselectedNamespace(searchParams.get('namespace')); }, [searchParams]); const clearPreselectedProject = () => { diff --git a/plugins/openchoreo-ci/package.json b/plugins/openchoreo-ci/package.json index 6c2b167b5..b7c9cd8d3 100644 --- a/plugins/openchoreo-ci/package.json +++ b/plugins/openchoreo-ci/package.json @@ -4,10 +4,26 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } + }, "publishConfig": { "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts", + "alpha": "dist/alpha.esm.js", "registry": "https://npm.pkg.github.com" }, "repository": { @@ -36,6 +52,7 @@ "@backstage/catalog-model": "^1.9.0", "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/plugin-catalog-react": "^3.0.0", "@backstage/theme": "^0.7.3", "@material-ui/core": "4.12.4", diff --git a/plugins/openchoreo-ci/src/alpha.tsx b/plugins/openchoreo-ci/src/alpha.tsx new file mode 100644 index 000000000..d144f97c0 --- /dev/null +++ b/plugins/openchoreo-ci/src/alpha.tsx @@ -0,0 +1,41 @@ +import { + ApiBlueprint, + createFrontendPlugin, + discoveryApiRef, + fetchApiRef, +} from '@backstage/frontend-plugin-api'; +import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; + +import { rootRouteRef } from './routes'; +import { openChoreoCiClientApiRef } from './api/OpenChoreoCiClientApi'; +import { OpenChoreoCiClient } from './api/OpenChoreoCiClient'; + +const ciClientApi = ApiBlueprint.make({ + name: 'open-choreo-ci-client', + params: defineParams => + defineParams({ + api: openChoreoCiClientApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new OpenChoreoCiClient(discoveryApi, fetchApi), + }), +}); + +const workflowsEntityContent = EntityContentBlueprint.make({ + name: 'workflows', + params: { + path: '/workflows', + title: 'Build', + filter: 'kind:component', + loader: () => import('./components/Workflows').then(m => ), + }, +}); + +/** + * NFS entry point for the OpenChoreo CI plugin. + */ +export default createFrontendPlugin({ + pluginId: 'openchoreo-ci', + routes: { root: rootRouteRef }, + extensions: [ciClientApi, workflowsEntityContent], +}); diff --git a/plugins/openchoreo-observability/package.json b/plugins/openchoreo-observability/package.json index b2bf2be6c..61d6ea26d 100644 --- a/plugins/openchoreo-observability/package.json +++ b/plugins/openchoreo-observability/package.json @@ -4,10 +4,26 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } + }, "publishConfig": { "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts", + "alpha": "dist/alpha.esm.js", "registry": "https://npm.pkg.github.com" }, "repository": { @@ -36,6 +52,7 @@ "@backstage/catalog-model": "^1.9.0", "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/plugin-catalog-react": "^3.0.0", "@backstage/theme": "^0.7.3", "@date-io/date-fns": "1.3.13", diff --git a/plugins/openchoreo-observability/src/alpha.test.tsx b/plugins/openchoreo-observability/src/alpha.test.tsx new file mode 100644 index 000000000..d8b390d23 --- /dev/null +++ b/plugins/openchoreo-observability/src/alpha.test.tsx @@ -0,0 +1,50 @@ +import observabilityPlugin, { + LogRowActionBlueprint, + logRowActionRendererApiRef, +} from './alpha'; + +describe('openchoreo-observability alpha plugin', () => { + it('registers under the openchoreo-observability plugin id', () => { + expect((observabilityPlugin as any).id).toBe('openchoreo-observability'); + }); + + it('re-exports the LogRowActionBlueprint and renderer api ref', () => { + expect(LogRowActionBlueprint).toBeDefined(); + expect(LogRowActionBlueprint.dataRefs.renderer).toBeDefined(); + expect(logRowActionRendererApiRef.id).toBe( + 'plugin.openchoreo-observability.log-row-action-renderer', + ); + }); + + it('exposes the expected blueprint extensions', () => { + const extensions = (observabilityPlugin as any).extensions as Array<{ + id: string; + }>; + expect(Array.isArray(extensions)).toBe(true); + + const ids = extensions.map(e => e.id); + const plugin = 'openchoreo-observability'; + for (const expected of [ + // backend client apis + `api:${plugin}/observability`, + `api:${plugin}/rca-agent`, + `api:${plugin}/finops-agent`, + // host-injection registry + `api:${plugin}/log-row-action-renderer`, + // component-page entity tabs + `entity-content:${plugin}/runtime-logs`, + `entity-content:${plugin}/runtime-events`, + `entity-content:${plugin}/metrics`, + `entity-content:${plugin}/alerts`, + `entity-content:${plugin}/wirelogs`, + // system-page entity tabs + `entity-content:${plugin}/project-runtime-logs`, + `entity-content:${plugin}/traces`, + `entity-content:${plugin}/project-incidents`, + `entity-content:${plugin}/rca-reports`, + `entity-content:${plugin}/cost-analysis`, + ]) { + expect(ids).toContain(expected); + } + }); +}); diff --git a/plugins/openchoreo-observability/src/alpha.tsx b/plugins/openchoreo-observability/src/alpha.tsx new file mode 100644 index 000000000..726a59de3 --- /dev/null +++ b/plugins/openchoreo-observability/src/alpha.tsx @@ -0,0 +1,291 @@ +import { + ApiBlueprint, + createExtensionInput, + createFrontendPlugin, + discoveryApiRef, + fetchApiRef, +} from '@backstage/frontend-plugin-api'; +import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { FeatureGatedContent } from '@openchoreo/backstage-plugin-react'; + +import { rootRouteRef } from './routes'; +import { + observabilityApiRef, + ObservabilityClient, +} from './api/ObservabilityApi'; +import { rcaAgentApiRef, RCAAgentClient } from './api/RCAAgentApi'; +import { finopsAgentApiRef, FinOpsAgentClient } from './api/FinOpsAgentApi'; +import { + DefaultLogRowActionRendererApi, + logRowActionRendererApiRef, +} from './api/LogRowActionRendererApi'; +import { LogRowActionBlueprint } from './alpha/LogRowActionBlueprint'; + +export { LogRowActionBlueprint } from './alpha/LogRowActionBlueprint'; +export { + logRowActionRendererApiRef, + type LogRowActionRendererApi, +} from './api/LogRowActionRendererApi'; + +const observabilityApi = ApiBlueprint.make({ + name: 'observability', + params: defineParams => + defineParams({ + api: observabilityApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new ObservabilityClient({ discoveryApi, fetchApi }), + }), +}); + +const rcaAgentApi = ApiBlueprint.make({ + name: 'rca-agent', + params: defineParams => + defineParams({ + api: rcaAgentApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new RCAAgentClient({ discoveryApi, fetchApi }), + }), +}); + +const finopsAgentApi = ApiBlueprint.make({ + name: 'finops-agent', + params: defineParams => + defineParams({ + api: finopsAgentApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new FinOpsAgentClient({ discoveryApi, fetchApi }), + }), +}); + +/** + * Registry API for host-injected log-row action renderers. Collects every + * `LogRowActionBlueprint` extension contributed by the host (or any other + * plugin) and exposes the first renderer via `useApi(logRowActionRendererApiRef)`. + * + * Mirrors upstream's `formDecoratorsApi` (plugin-scaffolder) — see + * `node_modules/@backstage/plugin-scaffolder/dist/alpha/api/FormDecoratorsApi.esm.js`. + */ +const logRowActionRendererApi = ApiBlueprint.makeWithOverrides({ + name: 'log-row-action-renderer', + inputs: { + renderers: createExtensionInput([LogRowActionBlueprint.dataRefs.renderer]), + }, + factory(originalFactory, { inputs }) { + const renderers = inputs.renderers.map(e => + e.get(LogRowActionBlueprint.dataRefs.renderer), + ); + return originalFactory(defineParams => + defineParams({ + api: logRowActionRendererApiRef, + deps: {}, + factory: () => DefaultLogRowActionRendererApi.create({ renderers }), + }), + ); + }, +}); + +/** + * Component-page entity tabs (kind:component). Each tab loads its page + * component lazily and wraps it in `FeatureGatedContent feature="observability"` + * so the tab is in-tree (so routing stays valid) but renders an + * empty-state when the host has observability disabled. + * + * The runtime-logs tab does NOT pass a `renderRowAction` prop — the page + * component reads the host-registered renderer through + * `useApiHolder().get(logRowActionRendererApiRef)` (see Step 1). + */ +const runtimeLogsEntityContent = EntityContentBlueprint.make({ + name: 'runtime-logs', + params: { + path: '/runtime-logs', + title: 'Logs', + filter: 'kind:component', + loader: () => + import('./components/RuntimeLogs/ObservabilityRuntimeLogsPage').then( + m => ( + + + + ), + ), + }, +}); + +const runtimeEventsEntityContent = EntityContentBlueprint.make({ + name: 'runtime-events', + params: { + path: '/runtime-events', + title: 'Events', + filter: 'kind:component', + loader: () => + import('./components/RuntimeEvents/ObservabilityRuntimeEventsPage').then( + m => ( + + + + ), + ), + }, +}); + +const metricsEntityContent = EntityContentBlueprint.make({ + name: 'metrics', + params: { + path: '/metrics', + title: 'Metrics', + filter: 'kind:component', + loader: () => + import('./components/Metrics/ObservabilityMetricsPage').then(m => ( + + + + )), + }, +}); + +const alertsEntityContent = EntityContentBlueprint.make({ + name: 'alerts', + params: { + path: '/alerts', + title: 'Alerts', + filter: 'kind:component', + loader: () => + import('./components/Alerts/ObservabilityAlertsPage').then(m => ( + + + + )), + }, +}); + +const wirelogsEntityContent = EntityContentBlueprint.make({ + name: 'wirelogs', + params: { + path: '/wirelogs', + title: 'Wirelogs', + filter: 'kind:component', + loader: () => + import('./components/Wirelogs/ObservabilityWirelogsPage').then(m => ( + + + + )), + }, +}); + +/** + * System-page (Project) entity tabs (kind:system). Same gating pattern + * as component-page tabs: lazy load + observability feature gate. The + * `/logs` tab uses `ObservabilityProjectRuntimeLogsPage` rather than the + * component-scoped runtime-logs page. + */ +const projectRuntimeLogsEntityContent = EntityContentBlueprint.make({ + name: 'project-runtime-logs', + params: { + path: '/logs', + title: 'Logs', + filter: 'kind:system', + loader: () => + import( + './components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage' + ).then(m => ( + + + + )), + }, +}); + +const tracesEntityContent = EntityContentBlueprint.make({ + name: 'traces', + params: { + path: '/traces', + title: 'Traces', + filter: 'kind:system', + loader: () => + import('./components/Traces/ObservabilityTracesPage').then(m => ( + + + + )), + }, +}); + +const projectIncidentsEntityContent = EntityContentBlueprint.make({ + name: 'project-incidents', + params: { + path: '/incidents', + title: 'Incidents', + filter: 'kind:system', + loader: () => + import('./components/Incidents/ObservabilityProjectIncidentsPage').then( + m => ( + + + + ), + ), + }, +}); + +const rcaReportsEntityContent = EntityContentBlueprint.make({ + name: 'rca-reports', + params: { + path: '/rca-reports', + title: 'RCA Reports', + filter: 'kind:system', + loader: () => + import('./components/RCA/RCAPage').then(m => ( + + + + )), + }, +}); + +const costAnalysisEntityContent = EntityContentBlueprint.make({ + name: 'cost-analysis', + params: { + path: '/cost-analysis', + title: 'Cost Analysis', + filter: 'kind:system', + loader: () => + import('./components/CostAnalysis').then(m => ( + + + + )), + }, +}); + +/** + * NFS entry point for the OpenChoreo Observability plugin. + * + * Registers the three observability backend clients, the log-row-action + * registry API, the component-page entity tabs (Logs, Events, Metrics, + * Alerts, Wirelogs) and the system-page entity tabs (Logs, Traces, + * Incidents, RCA Reports, Cost Analysis). + */ +export default createFrontendPlugin({ + pluginId: 'openchoreo-observability', + routes: { root: rootRouteRef }, + extensions: [ + observabilityApi, + rcaAgentApi, + finopsAgentApi, + logRowActionRendererApi, + runtimeLogsEntityContent, + runtimeEventsEntityContent, + metricsEntityContent, + alertsEntityContent, + wirelogsEntityContent, + projectRuntimeLogsEntityContent, + tracesEntityContent, + projectIncidentsEntityContent, + rcaReportsEntityContent, + costAnalysisEntityContent, + ], +}); diff --git a/plugins/openchoreo-observability/src/alpha/LogRowActionBlueprint.ts b/plugins/openchoreo-observability/src/alpha/LogRowActionBlueprint.ts new file mode 100644 index 000000000..dbbe1665b --- /dev/null +++ b/plugins/openchoreo-observability/src/alpha/LogRowActionBlueprint.ts @@ -0,0 +1,35 @@ +import { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; +import type { RenderLogRowAction } from '../components/RuntimeLogs/LogEntry'; + +const logRowActionRendererExtensionDataRef = + createExtensionDataRef().with({ + id: 'openchoreo-observability.log-row-action-renderer', + }); + +/** + * NFS extension blueprint that hosts use to contribute a per-row action + * renderer to the observability runtime-logs tables. + * + * Mirrors upstream's `FormDecoratorBlueprint` pattern. Each blueprint + * extension `attachTo`s the `renderers` input of + * `api:openchoreo-observability/log-row-action-renderer`; the API's + * factory then collects them into a single renderer that the logs + * tables consume via `useApi(logRowActionRendererApiRef)`. + */ +export const LogRowActionBlueprint = createExtensionBlueprint({ + kind: 'log-row-action-renderer', + attachTo: { + id: 'api:openchoreo-observability/log-row-action-renderer', + input: 'renderers', + }, + dataRefs: { + renderer: logRowActionRendererExtensionDataRef, + }, + output: [logRowActionRendererExtensionDataRef], + *factory(params: { renderer: RenderLogRowAction }) { + yield logRowActionRendererExtensionDataRef(params.renderer); + }, +}); diff --git a/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.test.ts b/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.test.ts new file mode 100644 index 000000000..9e6a322f4 --- /dev/null +++ b/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.test.ts @@ -0,0 +1,43 @@ +import { + DefaultLogRowActionRendererApi, + logRowActionRendererApiRef, +} from './LogRowActionRendererApi'; + +describe('logRowActionRendererApiRef', () => { + it('uses the canonical id under the openchoreo-observability plugin', () => { + expect(logRowActionRendererApiRef.id).toBe( + 'plugin.openchoreo-observability.log-row-action-renderer', + ); + }); +}); + +describe('DefaultLogRowActionRendererApi', () => { + it('exposes the first renderer when one is contributed', () => { + const sentinel = 'rendered-action' as any; + const renderer = jest.fn().mockReturnValue(sentinel); + + const api = DefaultLogRowActionRendererApi.create({ + renderers: [renderer], + }); + + const fakeLog = { id: '1' } as any; + const getSnapshot = jest.fn(); + expect(api.render(fakeLog, getSnapshot)).toBe(sentinel); + expect(renderer).toHaveBeenCalledWith(fakeLog, getSnapshot); + }); + + it('falls back to a no-op renderer when no renderers are contributed', () => { + const api = DefaultLogRowActionRendererApi.create({ renderers: [] }); + expect(api.render({} as any, jest.fn())).toBeNull(); + }); + + it('picks the first renderer when multiple are contributed', () => { + const first = jest.fn().mockReturnValue('first'); + const second = jest.fn().mockReturnValue('second'); + const api = DefaultLogRowActionRendererApi.create({ + renderers: [first as any, second as any], + }); + expect(api.render({} as any, jest.fn())).toBe('first'); + expect(second).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.ts b/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.ts new file mode 100644 index 000000000..3affdc1b5 --- /dev/null +++ b/plugins/openchoreo-observability/src/api/LogRowActionRendererApi.ts @@ -0,0 +1,39 @@ +import { createApiRef } from '@backstage/core-plugin-api'; +import type { RenderLogRowAction } from '../components/RuntimeLogs/LogEntry'; + +/** + * Registry API for the host-injected per-row action renderer used by + * the observability runtime-logs tables (`ObservabilityRuntimeLogs`, + * `ObservabilityProjectRuntimeLogs`). + * + * Under NFS, the host registers a `LogRowActionBlueprint` extension whose + * factory yields a `RenderLogRowAction`. This API's factory collects + * those extensions (via `inputs.renderers` on the alpha plugin) and + * exposes the first one as `render`. The logs components consume this + * API via `useApi(logRowActionRendererApiRef)`. + * + * When no extension is registered, `render` is a no-op that returns + * `null`, so the action column simply doesn't render. + */ +export interface LogRowActionRendererApi { + render: RenderLogRowAction; +} + +export const logRowActionRendererApiRef = createApiRef( + { + id: 'plugin.openchoreo-observability.log-row-action-renderer', + }, +); + +export class DefaultLogRowActionRendererApi implements LogRowActionRendererApi { + readonly render: RenderLogRowAction; + + private constructor(render: RenderLogRowAction) { + this.render = render; + } + + static create(options: { renderers: RenderLogRowAction[] }) { + const render: RenderLogRowAction = options.renderers[0] ?? (() => null); + return new DefaultLogRowActionRendererApi(render); + } +} diff --git a/plugins/openchoreo-observability/src/api/index.ts b/plugins/openchoreo-observability/src/api/index.ts index 34fb8637f..77baa5b2f 100644 --- a/plugins/openchoreo-observability/src/api/index.ts +++ b/plugins/openchoreo-observability/src/api/index.ts @@ -19,3 +19,9 @@ export { type FinOpsAgentApi, type FinOpsRoutingContext, } from './FinOpsAgentApi'; + +export { + logRowActionRendererApiRef, + DefaultLogRowActionRendererApi, + type LogRowActionRendererApi, +} from './LogRowActionRendererApi'; diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx index a02a108a9..ef3f75597 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Typography, Button } from '@material-ui/core'; import { Progress } from '@backstage/core-components'; +import { useApiHolder } from '@backstage/core-plugin-api'; import { Alert } from '@material-ui/lab'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; @@ -20,6 +21,7 @@ import { import { useRuntimeLogsStyles } from './styles'; import { LogEntryField } from './types'; import type { RenderLogRowAction } from './LogEntry'; +import { logRowActionRendererApiRef } from '../../api/LogRowActionRendererApi'; export interface ObservabilityProjectRuntimeLogsPageProps { renderRowAction?: RenderLogRowAction; @@ -259,6 +261,14 @@ export const ObservabilityProjectRuntimeLogsPage = ({ permissionName, } = useLogsPermission(); + // Prop wins for legacy callers; under NFS, fall back to the + // host-registered renderer collected by the alpha plugin's + // logRowActionRendererApi. useApiHolder + get returns undefined when + // the API isn't registered, so legacy-only hosts stay no-op. + const apiHolder = useApiHolder(); + const effectiveRenderRowAction: RenderLogRowAction | undefined = + renderRowAction ?? apiHolder.get(logRowActionRendererApiRef)?.render; + if (permissionLoading) { return ; } @@ -274,6 +284,8 @@ export const ObservabilityProjectRuntimeLogsPage = ({ } return ( - + ); }; diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx index 44df01637..00320a2f1 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Box, Typography, Button } from '@material-ui/core'; import { Progress } from '@backstage/core-components'; +import { useApiHolder } from '@backstage/core-plugin-api'; import { Alert } from '@material-ui/lab'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; @@ -20,6 +21,7 @@ import { import { useRuntimeLogsStyles } from './styles'; import { LOG_LEVELS } from './types'; import type { RenderLogRowAction } from './LogEntry'; +import { logRowActionRendererApiRef } from '../../api/LogRowActionRendererApi'; export interface ObservabilityRuntimeLogsPageProps { renderRowAction?: RenderLogRowAction; @@ -275,6 +277,14 @@ export const ObservabilityRuntimeLogsPage = ({ permissionName, } = useLogsPermission(); + // Prop wins for legacy callers; under NFS, fall back to the + // host-registered renderer collected by the alpha plugin's + // logRowActionRendererApi. useApiHolder + get returns undefined when + // the API isn't registered, so legacy-only hosts stay no-op. + const apiHolder = useApiHolder(); + const effectiveRenderRowAction: RenderLogRowAction | undefined = + renderRowAction ?? apiHolder.get(logRowActionRendererApiRef)?.render; + if (permissionLoading) { return ; } @@ -289,5 +299,9 @@ export const ObservabilityRuntimeLogsPage = ({ ); } - return ; + return ( + + ); }; diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index 9358cf5a0..f37c0ac32 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -12,3 +12,7 @@ export { ObservabilityCostAnalysis, } from './plugin'; export type { RenderLogRowAction } from './components/RuntimeLogs/LogEntry'; +export { + logRowActionRendererApiRef, + type LogRowActionRendererApi, +} from './api/LogRowActionRendererApi'; diff --git a/plugins/openchoreo-observability/src/plugin.ts b/plugins/openchoreo-observability/src/plugin.ts index 7fee0734a..1dc5c901f 100644 --- a/plugins/openchoreo-observability/src/plugin.ts +++ b/plugins/openchoreo-observability/src/plugin.ts @@ -1,6 +1,6 @@ +import { lazy } from 'react'; import { createPlugin, - createRoutableExtension, createApiFactory, discoveryApiRef, fetchApiRef, @@ -50,109 +50,69 @@ export const openchoreoObservabilityPlugin = createPlugin({ ], }); -export const ObservabilityMetrics = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityMetrics', - component: () => - import('./components/Metrics/ObservabilityMetricsPage').then( - m => m.ObservabilityMetricsPage, - ), - mountPoint: rootRouteRef, - }), +/** + * Entity-page tab components. Previously wrapped in + * `createRoutableExtension({ mountPoint: rootRouteRef })`, but `rootRouteRef` + * is never bound to a real mounted path — these are tab content, not + * standalone pages, so the routable wrapper would throw + * "Routable extension component was not discovered in the app element tree" + * at render time. Exported as `React.lazy` components so the page bundle + * still code-splits. + */ +export const ObservabilityMetrics = lazy(() => + import('./components/Metrics/ObservabilityMetricsPage').then(m => ({ + default: m.ObservabilityMetricsPage, + })), ); -export const ObservabilityTraces = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityTraces', - component: () => - import('./components/Traces/ObservabilityTracesPage').then( - m => m.ObservabilityTracesPage, - ), - mountPoint: rootRouteRef, - }), +export const ObservabilityTraces = lazy(() => + import('./components/Traces/ObservabilityTracesPage').then(m => ({ + default: m.ObservabilityTracesPage, + })), ); -export const ObservabilityRCA = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityRCA', - component: () => import('./components/RCA/RCAPage').then(m => m.RCAPage), - mountPoint: rootRouteRef, - }), +export const ObservabilityRCA = lazy(() => + import('./components/RCA/RCAPage').then(m => ({ default: m.RCAPage })), ); -export const ObservabilityRuntimeLogs = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityRuntimeLogs', - component: () => - import('./components/RuntimeLogs/ObservabilityRuntimeLogsPage').then( - m => m.ObservabilityRuntimeLogsPage, - ), - mountPoint: rootRouteRef, - }), +export const ObservabilityRuntimeLogs = lazy(() => + import('./components/RuntimeLogs/ObservabilityRuntimeLogsPage').then(m => ({ + default: m.ObservabilityRuntimeLogsPage, + })), ); -export const ObservabilityRuntimeEvents = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityRuntimeEvents', - component: () => - import('./components/RuntimeEvents/ObservabilityRuntimeEventsPage').then( - m => m.ObservabilityRuntimeEventsPage, - ), - mountPoint: rootRouteRef, - }), +export const ObservabilityRuntimeEvents = lazy(() => + import('./components/RuntimeEvents/ObservabilityRuntimeEventsPage').then( + m => ({ default: m.ObservabilityRuntimeEventsPage }), + ), ); -export const ObservabilityProjectRuntimeLogs = - openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityProjectRuntimeLogs', - component: () => - import( - './components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage' - ).then(m => m.ObservabilityProjectRuntimeLogsPage), - mountPoint: rootRouteRef, - }), - ); +export const ObservabilityProjectRuntimeLogs = lazy(() => + import('./components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage').then( + m => ({ default: m.ObservabilityProjectRuntimeLogsPage }), + ), +); -export const ObservabilityAlerts = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityAlerts', - component: () => - import('./components/Alerts/ObservabilityAlertsPage').then( - m => m.ObservabilityAlertsPage, - ), - mountPoint: rootRouteRef, - }), +export const ObservabilityAlerts = lazy(() => + import('./components/Alerts/ObservabilityAlertsPage').then(m => ({ + default: m.ObservabilityAlertsPage, + })), ); -export const ObservabilityWirelogs = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityWirelogs', - component: () => - import('./components/Wirelogs/ObservabilityWirelogsPage').then( - m => m.ObservabilityWirelogsPage, - ), - mountPoint: rootRouteRef, - }), +export const ObservabilityWirelogs = lazy(() => + import('./components/Wirelogs/ObservabilityWirelogsPage').then(m => ({ + default: m.ObservabilityWirelogsPage, + })), ); -export const ObservabilityProjectIncidents = - openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityProjectIncidents', - component: () => - import('./components/Incidents/ObservabilityProjectIncidentsPage').then( - m => m.ObservabilityProjectIncidentsPage, - ), - mountPoint: rootRouteRef, - }), - ); +export const ObservabilityProjectIncidents = lazy(() => + import('./components/Incidents/ObservabilityProjectIncidentsPage').then( + m => ({ default: m.ObservabilityProjectIncidentsPage }), + ), +); -export const ObservabilityCostAnalysis = openchoreoObservabilityPlugin.provide( - createRoutableExtension({ - name: 'ObservabilityCostAnalysis', - component: () => - import('./components/CostAnalysis').then(m => m.CostAnalysisPage), - mountPoint: rootRouteRef, - }), +export const ObservabilityCostAnalysis = lazy(() => + import('./components/CostAnalysis').then(m => ({ + default: m.CostAnalysisPage, + })), ); diff --git a/packages/app/src/components/catalog/FeatureGatedContent.tsx b/plugins/openchoreo-react/src/components/FeatureGate/FeatureGatedContent.tsx similarity index 67% rename from packages/app/src/components/catalog/FeatureGatedContent.tsx rename to plugins/openchoreo-react/src/components/FeatureGate/FeatureGatedContent.tsx index 0f161f96a..aaf38a5d9 100644 --- a/packages/app/src/components/catalog/FeatureGatedContent.tsx +++ b/plugins/openchoreo-react/src/components/FeatureGate/FeatureGatedContent.tsx @@ -1,21 +1,17 @@ import { ReactNode } from 'react'; -import { useOpenChoreoFeatures } from '@openchoreo/backstage-plugin-react'; -import type { FeatureName } from '@openchoreo/backstage-plugin-common'; import { EmptyState } from '@backstage/core-components'; +import type { FeatureName } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoFeatures } from '../../hooks/useOpenChoreoFeatures'; -interface FeatureGatedContentProps { +export interface FeatureGatedContentProps { feature: FeatureName; children: ReactNode; } /** - * Wrapper component for feature-gated route content. - * - * Unlike FeatureGate which conditionally renders children, - * this component always renders something (either the children or an empty state). - * This is required for routable extensions that must be present in the element tree. - * - * When the feature is disabled, shows an empty state message instead of the content. + * Routable variant of {@link FeatureGate}. Returns an {@link EmptyState} when + * the feature is disabled instead of `null`, so it remains valid as the body + * of a routable extension (`EntityContentBlueprint` loader, etc.). */ export function FeatureGatedContent({ feature, diff --git a/plugins/openchoreo-react/src/components/FeatureGate/index.ts b/plugins/openchoreo-react/src/components/FeatureGate/index.ts index 4a08f333d..8710ded36 100644 --- a/plugins/openchoreo-react/src/components/FeatureGate/index.ts +++ b/plugins/openchoreo-react/src/components/FeatureGate/index.ts @@ -3,3 +3,7 @@ export { withFeatureGate, type FeatureGateProps, } from './FeatureGate'; +export { + FeatureGatedContent, + type FeatureGatedContentProps, +} from './FeatureGatedContent'; diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index 1fc54f765..28eb8bf0d 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -8,8 +8,10 @@ export { SummaryWidgetWrapper } from './components/SummaryWidgetWrapper'; export { FeatureGate, + FeatureGatedContent, withFeatureGate, type FeatureGateProps, + type FeatureGatedContentProps, } from './components/FeatureGate'; export { AnnotationGate, diff --git a/plugins/openchoreo-workflows/package.json b/plugins/openchoreo-workflows/package.json index bdf86d713..2cd5fd5e2 100644 --- a/plugins/openchoreo-workflows/package.json +++ b/plugins/openchoreo-workflows/package.json @@ -4,10 +4,26 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } + }, "publishConfig": { "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts", + "alpha": "dist/alpha.esm.js", "registry": "https://npm.pkg.github.com" }, "repository": { @@ -34,6 +50,7 @@ "dependencies": { "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/plugin-catalog-react": "^3.0.0", "@backstage/theme": "^0.7.3", "@material-ui/core": "4.12.4", diff --git a/plugins/openchoreo-workflows/src/alpha.test.tsx b/plugins/openchoreo-workflows/src/alpha.test.tsx new file mode 100644 index 000000000..2947a3131 --- /dev/null +++ b/plugins/openchoreo-workflows/src/alpha.test.tsx @@ -0,0 +1,24 @@ +import workflowsPlugin from './alpha'; + +describe('openchoreo-workflows alpha plugin', () => { + it('registers under the openchoreo-workflows plugin id', () => { + expect((workflowsPlugin as any).id).toBe('openchoreo-workflows'); + }); + + it('exposes the expected blueprint extensions', () => { + const extensions = (workflowsPlugin as any).extensions as Array<{ + id: string; + }>; + expect(Array.isArray(extensions)).toBe(true); + + const ids = extensions.map(e => e.id); + const plugin = 'openchoreo-workflows'; + for (const expected of [ + `api:${plugin}/generic-workflows-client`, + `page:${plugin}/generic-workflows`, + `entity-content:${plugin}/workflow-runs`, + ]) { + expect(ids).toContain(expected); + } + }); +}); diff --git a/plugins/openchoreo-workflows/src/alpha.tsx b/plugins/openchoreo-workflows/src/alpha.tsx new file mode 100644 index 000000000..3f5256628 --- /dev/null +++ b/plugins/openchoreo-workflows/src/alpha.tsx @@ -0,0 +1,74 @@ +import { + ApiBlueprint, + createFrontendPlugin, + discoveryApiRef, + fetchApiRef, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; + +import { rootRouteRef } from './routes'; +import { genericWorkflowsClientApiRef } from './api/GenericWorkflowsClientApi'; +import { GenericWorkflowsClient } from './api/GenericWorkflowsClient'; + +const genericWorkflowsClientApi = ApiBlueprint.make({ + name: 'generic-workflows-client', + params: defineParams => + defineParams({ + api: genericWorkflowsClientApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new GenericWorkflowsClient(discoveryApi, fetchApi), + }), +}); + +const genericWorkflowsPage = PageBlueprint.make({ + name: 'generic-workflows', + params: { + path: '/workflows', + routeRef: rootRouteRef, + loader: () => + import('./components/GenericWorkflowsPage').then(m => ( + + )), + }, +}); + +/** + * Workflow Runs entity tab — mounts under workflow and clusterworkflow + * kinds, but only for entries with `spec.type === 'Generic'`. The + * EntityNamespaceProvider supplies the entity's namespace to the runs + * table via React context, matching the legacy `EntityPage.tsx` mount. + */ +const workflowRunsEntityContent = EntityContentBlueprint.make({ + name: 'workflow-runs', + params: { + path: '/runs', + title: 'Runs', + filter: entity => + ['workflow', 'clusterworkflow'].includes(entity.kind.toLowerCase()) && + (entity.spec as { type?: string } | undefined)?.type === 'Generic', + loader: () => + Promise.all([ + import('./components/WorkflowRunsContent'), + import('./components/EntityNamespaceProvider'), + ]).then(([runs, provider]) => ( + + + + )), + }, +}); + +/** + * NFS entry point for the OpenChoreo generic-workflows plugin. + */ +export default createFrontendPlugin({ + pluginId: 'openchoreo-workflows', + routes: { root: rootRouteRef }, + extensions: [ + genericWorkflowsClientApi, + genericWorkflowsPage, + workflowRunsEntityContent, + ], +}); diff --git a/plugins/openchoreo/package.json b/plugins/openchoreo/package.json index 5d579e5d4..f5b215dd3 100644 --- a/plugins/openchoreo/package.json +++ b/plugins/openchoreo/package.json @@ -4,6 +4,21 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } + }, "repository": { "type": "git", "url": "https://github.com/openchoreo/backstage-plugins.git", @@ -13,6 +28,7 @@ "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts", + "alpha": "dist/alpha.esm.js", "registry": "https://npm.pkg.github.com" }, "backstage": { @@ -37,6 +53,7 @@ "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", "@backstage/errors": "^1.3.1", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/plugin-catalog-react": "^3.0.0", "@backstage/plugin-permission-react": "^0.5.1", "@backstage/theme": "^0.7.3", diff --git a/plugins/openchoreo/src/alpha.test.tsx b/plugins/openchoreo/src/alpha.test.tsx new file mode 100644 index 000000000..1534d356d --- /dev/null +++ b/plugins/openchoreo/src/alpha.test.tsx @@ -0,0 +1,77 @@ +import openchoreoPlugin from './alpha'; + +const ALPHA_EXTENSION_NAMES = [ + // backend client + ['api', 'open-choreo-client'], + // shared + ['entity-content', 'resource-definition'], + // component-page + ['entity-content', 'component-deploy'], + ['entity-card', 'deployment-status'], + ['entity-card', 'runtime-health'], + // system-page + ['entity-content', 'cell-diagram'], + ['entity-card', 'project-contents'], + ['entity-card', 'deployment-pipeline'], + // domain-page + ['entity-card', 'namespace-projects'], + ['entity-card', 'namespace-resources'], + // managed resource + ['entity-content', 'resource-deploy'], + ['entity-card', 'resource-parameters'], + ['entity-card', 'resource-deployments'], + ['entity-card', 'consuming-components'], + // environment + ['entity-card', 'environment-status-summary'], + ['entity-card', 'environment-promotion'], + ['entity-card', 'environment-deployed-components'], + ['entity-card', 'environment-gateway-configuration'], + // dataplane + ['entity-card', 'dataplane-status'], + ['entity-card', 'dataplane-environments'], + ['entity-card', 'dataplane-gateway-configuration'], + ['entity-card', 'cluster-dataplane-status'], + ['entity-card', 'cluster-dataplane-environments'], + ['entity-card', 'cluster-dataplane-gateway-configuration'], + // workflow plane + ['entity-card', 'workflow-plane-status'], + ['entity-card', 'cluster-workflow-plane-status'], + // observability plane + ['entity-card', 'observability-plane-status'], + ['entity-card', 'observability-plane-linked-planes'], + ['entity-card', 'cluster-observability-plane-status'], + ['entity-card', 'cluster-observability-plane-linked-planes'], + // deployment pipeline + ['entity-card', 'deployment-pipeline-visualization'], + ['entity-card', 'promotion-paths'], + // type families + ['entity-card', 'component-type-overview'], + ['entity-card', 'resource-type-overview'], + ['entity-card', 'trait-type-overview'], + // workflow family + ['entity-card', 'workflow-overview'], + ['entity-card', 'component-workflow-overview'], +] as const; + +describe('openchoreo alpha plugin', () => { + it('registers under the openchoreo plugin id', () => { + expect((openchoreoPlugin as any).id).toBe('openchoreo'); + }); + + it('exposes the documented blueprint extensions', () => { + const extensions = (openchoreoPlugin as any).extensions as Array<{ + id: string; + }>; + expect(Array.isArray(extensions)).toBe(true); + + const ids = extensions.map(e => e.id); + for (const [kind, name] of ALPHA_EXTENSION_NAMES) { + expect(ids).toContain(`${kind}:openchoreo/${name}`); + } + }); + + it('exposes one extension per documented entry (no silent drops)', () => { + const extensions = (openchoreoPlugin as any).extensions as Array; + expect(extensions).toHaveLength(ALPHA_EXTENSION_NAMES.length); + }); +}); diff --git a/plugins/openchoreo/src/alpha.tsx b/plugins/openchoreo/src/alpha.tsx new file mode 100644 index 000000000..cca8647c6 --- /dev/null +++ b/plugins/openchoreo/src/alpha.tsx @@ -0,0 +1,568 @@ +import { + ApiBlueprint, + createFrontendPlugin, + discoveryApiRef, + fetchApiRef, +} from '@backstage/frontend-plugin-api'; +import { + EntityCardBlueprint, + EntityContentBlueprint, +} from '@backstage/plugin-catalog-react/alpha'; +import { CHOREO_LABELS } from '@openchoreo/backstage-plugin-common'; +import { FeatureGate } from '@openchoreo/backstage-plugin-react'; + +import { + rootCatalogEnvironmentRouteRef, + accessControlRouteRef, + resourceEnvironmentsRouteRef, +} from './routes'; +import { openChoreoClientApiRef } from './api/OpenChoreoClientApi'; +import { OpenChoreoClient } from './api/OpenChoreoClient'; + +const openChoreoClientApi = ApiBlueprint.make({ + name: 'open-choreo-client', + params: defineParams => + defineParams({ + api: openChoreoClientApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new OpenChoreoClient(discoveryApi, fetchApi), + }), +}); + +// ─── Shared filter for any kind that needs the resource-definition tab ────── +// +// ResourceDefinitionTab is reused on ~20 entity kinds. Register it once with +// a callable filter (rather than 20 string-filter blueprints) so it stays +// a single registration in the alpha export and a single line in the +// extension array. +const KINDS_WITH_RESOURCE_DEFINITION = new Set([ + 'component', + 'system', + 'domain', + 'resource', + 'environment', + 'dataplane', + 'clusterdataplane', + 'workflowplane', + 'clusterworkflowplane', + 'observabilityplane', + 'clusterobservabilityplane', + 'deploymentpipeline', + 'componenttype', + 'resourcetype', + 'clustercomponenttype', + 'clusterresourcetype', + 'traittype', + 'clustertraittype', + 'workflow', + 'clusterworkflow', + 'componentworkflow', +]); + +const resourceDefinitionEntityContent = EntityContentBlueprint.make({ + name: 'resource-definition', + params: { + path: '/definition', + title: 'Definition', + filter: entity => + KINDS_WITH_RESOURCE_DEFINITION.has(entity.kind.toLowerCase()), + loader: () => + import('./components/ResourceDefinition').then(m => ( + + )), + }, +}); + +// ─── Component-page tabs (kind:component) ───────────────────────────────── +const componentDeployEntityContent = EntityContentBlueprint.make({ + name: 'component-deploy', + params: { + path: '/environments', + title: 'Deploy', + filter: 'kind:component', + loader: () => + import('./components/Environments/Environments').then(m => ( + + )), + }, +}); + +// ─── Component-page Overview cards (kind:component) ─────────────────────── +const deploymentStatusCard = EntityCardBlueprint.make({ + name: 'deployment-status', + params: { + filter: 'kind:component', + loader: () => + import('./components/Environments').then(m => ), + }, +}); + +// RuntimeHealthCard is observability-gated. FeatureGate (returns null when +// disabled) is the right wrapper because cards can vanish without breaking +// any route — unlike EntityContent, which must remain in tree. +const runtimeHealthCard = EntityCardBlueprint.make({ + name: 'runtime-health', + params: { + filter: 'kind:component', + loader: () => + import('./components/RuntimeLogs').then(m => ( + + + + )), + }, +}); + +// ─── System (project) page tabs + cards (kind:system) ───────────────────── +const cellDiagramEntityContent = EntityContentBlueprint.make({ + name: 'cell-diagram', + params: { + path: '/cell-diagram', + title: 'Cell Diagram', + filter: 'kind:system', + loader: () => + import('./components/CellDiagram/CellDiagram').then(m => ( + + )), + }, +}); + +const projectContentsCard = EntityCardBlueprint.make({ + name: 'project-contents', + params: { + filter: 'kind:system', + loader: () => + import('./components/Projects/ProjectContentsCard').then(m => ( + + )), + }, +}); + +const deploymentPipelineCard = EntityCardBlueprint.make({ + name: 'deployment-pipeline', + params: { + filter: 'kind:system', + loader: () => + import('./components/Projects/OverviewCards').then(m => ( + + )), + }, +}); + +// ─── Domain (namespace) page cards (kind:domain) ────────────────────────── +const namespaceProjectsCard = EntityCardBlueprint.make({ + name: 'namespace-projects', + params: { + filter: 'kind:domain', + loader: () => + import('./components/Namespaces').then(m => ), + }, +}); + +const namespaceResourcesCard = EntityCardBlueprint.make({ + name: 'namespace-resources', + params: { + filter: 'kind:domain', + loader: () => + import('./components/Namespaces').then(m => ), + }, +}); + +// ─── Resource page (managed) tab + cards ────────────────────────────────── +// +// Resources are kind:resource but only "OpenChoreo-managed" resources (a +// label-based discriminator) get this layout. Use a callable filter that +// matches on the CHOREO_LABELS.MANAGED label; consumers without the label +// fall through to upstream's default resource page. +const isOpenChoreoManagedResource = ( + entity: import('@backstage/catalog-model').Entity, +) => + entity.kind.toLowerCase() === 'resource' && + entity.metadata.labels?.[CHOREO_LABELS.MANAGED] === 'true'; + +const resourceDeployEntityContent = EntityContentBlueprint.make({ + name: 'resource-deploy', + params: { + path: '/environments', + title: 'Deploy', + filter: isOpenChoreoManagedResource, + loader: () => + import('./components/ResourceEnvironments').then(m => ( + + )), + }, +}); + +const resourceParametersCard = EntityCardBlueprint.make({ + name: 'resource-parameters', + params: { + filter: isOpenChoreoManagedResource, + loader: () => + import('./components/ResourceOverview').then(m => ( + + )), + }, +}); + +const resourceDeploymentsCard = EntityCardBlueprint.make({ + name: 'resource-deployments', + params: { + filter: isOpenChoreoManagedResource, + loader: () => + import('./components/ResourceOverview').then(m => ( + + )), + }, +}); + +const consumingComponentsCard = EntityCardBlueprint.make({ + name: 'consuming-components', + params: { + filter: isOpenChoreoManagedResource, + loader: () => + import('./components/ResourceOverview').then(m => ( + + )), + }, +}); + +// ─── Environment page cards (kind:environment) ──────────────────────────── +const environmentStatusSummaryCard = EntityCardBlueprint.make({ + name: 'environment-status-summary', + params: { + filter: 'kind:environment', + loader: () => + import('./components/EnvironmentOverview').then(m => ( + + )), + }, +}); + +const environmentPromotionCard = EntityCardBlueprint.make({ + name: 'environment-promotion', + params: { + filter: 'kind:environment', + loader: () => + import('./components/EnvironmentOverview').then(m => ( + + )), + }, +}); + +const environmentDeployedComponentsCard = EntityCardBlueprint.make({ + name: 'environment-deployed-components', + params: { + filter: 'kind:environment', + loader: () => + import('./components/EnvironmentOverview').then(m => ( + + )), + }, +}); + +const environmentGatewayConfigurationCard = EntityCardBlueprint.make({ + name: 'environment-gateway-configuration', + params: { + filter: 'kind:environment', + loader: () => + import('./components/EnvironmentOverview').then(m => ( + + )), + }, +}); + +// ─── Dataplane page cards (kind:dataplane) ──────────────────────────────── +const dataplaneStatusCard = EntityCardBlueprint.make({ + name: 'dataplane-status', + params: { + filter: 'kind:dataplane', + loader: () => + import('./components/DataplaneOverview').then(m => ( + + )), + }, +}); + +const dataplaneEnvironmentsCard = EntityCardBlueprint.make({ + name: 'dataplane-environments', + params: { + filter: 'kind:dataplane', + loader: () => + import('./components/DataplaneOverview').then(m => ( + + )), + }, +}); + +const dataplaneGatewayConfigurationCard = EntityCardBlueprint.make({ + name: 'dataplane-gateway-configuration', + params: { + filter: 'kind:dataplane', + loader: () => + import('./components/DataplaneOverview').then(m => ( + + )), + }, +}); + +// ─── ClusterDataplane page cards (kind:clusterdataplane) ────────────────── +const clusterDataplaneStatusCard = EntityCardBlueprint.make({ + name: 'cluster-dataplane-status', + params: { + filter: 'kind:clusterdataplane', + loader: () => + import('./components/ClusterDataplaneOverview').then(m => ( + + )), + }, +}); + +const clusterDataplaneEnvironmentsCard = EntityCardBlueprint.make({ + name: 'cluster-dataplane-environments', + params: { + filter: 'kind:clusterdataplane', + loader: () => + import('./components/ClusterDataplaneOverview').then(m => ( + + )), + }, +}); + +const clusterDataplaneGatewayConfigurationCard = EntityCardBlueprint.make({ + name: 'cluster-dataplane-gateway-configuration', + params: { + filter: 'kind:clusterdataplane', + loader: () => + import('./components/ClusterDataplaneOverview').then(m => ( + + )), + }, +}); + +// ─── WorkflowPlane / ClusterWorkflowPlane cards ─────────────────────────── +const workflowPlaneStatusCard = EntityCardBlueprint.make({ + name: 'workflow-plane-status', + params: { + filter: 'kind:workflowplane', + loader: () => + import('./components/WorkflowPlaneOverview').then(m => ( + + )), + }, +}); + +const clusterWorkflowPlaneStatusCard = EntityCardBlueprint.make({ + name: 'cluster-workflow-plane-status', + params: { + filter: 'kind:clusterworkflowplane', + loader: () => + import('./components/ClusterWorkflowPlaneOverview').then(m => ( + + )), + }, +}); + +// ─── ObservabilityPlane / ClusterObservabilityPlane cards ───────────────── +const observabilityPlaneStatusCard = EntityCardBlueprint.make({ + name: 'observability-plane-status', + params: { + filter: 'kind:observabilityplane', + loader: () => + import('./components/ObservabilityPlaneOverview').then(m => ( + + )), + }, +}); + +const observabilityPlaneLinkedPlanesCard = EntityCardBlueprint.make({ + name: 'observability-plane-linked-planes', + params: { + filter: 'kind:observabilityplane', + loader: () => + import('./components/ObservabilityPlaneOverview').then(m => ( + + )), + }, +}); + +const clusterObservabilityPlaneStatusCard = EntityCardBlueprint.make({ + name: 'cluster-observability-plane-status', + params: { + filter: 'kind:clusterobservabilityplane', + loader: () => + import('./components/ClusterObservabilityPlaneOverview').then(m => ( + + )), + }, +}); + +const clusterObservabilityPlaneLinkedPlanesCard = EntityCardBlueprint.make({ + name: 'cluster-observability-plane-linked-planes', + params: { + filter: 'kind:clusterobservabilityplane', + loader: () => + import('./components/ClusterObservabilityPlaneOverview').then(m => ( + + )), + }, +}); + +// ─── DeploymentPipeline page cards (kind:deploymentpipeline) ────────────── +const deploymentPipelineVisualizationCard = EntityCardBlueprint.make({ + name: 'deployment-pipeline-visualization', + params: { + filter: 'kind:deploymentpipeline', + loader: () => + import('./components/DeploymentPipelineOverview').then(m => ( + + )), + }, +}); + +const promotionPathsCard = EntityCardBlueprint.make({ + name: 'promotion-paths', + params: { + filter: 'kind:deploymentpipeline', + loader: () => + import('./components/DeploymentPipelineOverview').then(m => ( + + )), + }, +}); + +// ─── *Type overview cards (componenttype / resourcetype / traittype) ────── +// +// ComponentTypeOverviewCard is reused on kind:componenttype AND +// kind:clustercomponenttype — register once with a multi-kind callable +// filter rather than two near-identical blueprints. Same shape for the +// resource-type and trait-type variants. +const componentTypeOverviewCard = EntityCardBlueprint.make({ + name: 'component-type-overview', + params: { + filter: entity => + ['componenttype', 'clustercomponenttype'].includes( + entity.kind.toLowerCase(), + ), + loader: () => + import('./components/ComponentTypeOverview').then(m => ( + + )), + }, +}); + +const resourceTypeOverviewCard = EntityCardBlueprint.make({ + name: 'resource-type-overview', + params: { + filter: entity => + ['resourcetype', 'clusterresourcetype'].includes( + entity.kind.toLowerCase(), + ), + loader: () => + import('./components/ResourceTypeOverview').then(m => ( + + )), + }, +}); + +const traitTypeOverviewCard = EntityCardBlueprint.make({ + name: 'trait-type-overview', + params: { + filter: entity => + ['traittype', 'clustertraittype'].includes(entity.kind.toLowerCase()), + loader: () => + import('./components/TraitTypeOverview').then(m => ( + + )), + }, +}); + +// ─── Workflow / ClusterWorkflow / ComponentWorkflow overview cards ──────── +const workflowOverviewCard = EntityCardBlueprint.make({ + name: 'workflow-overview', + params: { + filter: entity => + ['workflow', 'clusterworkflow'].includes(entity.kind.toLowerCase()), + loader: () => + import('./components/WorkflowOverview').then(m => ( + + )), + }, +}); + +const componentWorkflowOverviewCard = EntityCardBlueprint.make({ + name: 'component-workflow-overview', + params: { + filter: 'kind:componentworkflow', + loader: () => + import('./components/ComponentWorkflowOverview').then(m => ( + + )), + }, +}); + +/** + * NFS entry point for the OpenChoreo plugin. + * + * Registers the OpenChoreoClient API, the cross-kind ResourceDefinitionTab, + * the component-page Deploy tab + DeploymentStatus/RuntimeHealth cards, the + * system-page Cell Diagram tab + ProjectContents/DeploymentPipeline cards, + * the domain-page Namespace cards, the managed-resource Deploy tab + cards, + * and the per-kind overview cards for every OpenChoreo platform kind + * (Environment, DataPlane/ClusterDataPlane, WorkflowPlane/ClusterWorkflowPlane, + * ObservabilityPlane/ClusterObservabilityPlane, DeploymentPipeline, + * ComponentType/ResourceType/TraitType + cluster variants, + * Workflow/ClusterWorkflow/ComponentWorkflow). + * + * Host-only mounts (OpenChoreoAboutCard, EntityCatalogGraphCard with custom + * relations, WorkflowsOrExternalCICard, the Overview FailedBuildSnackbar) stay + * in `packages/app` and ride through `customAppModule` because they belong + * to the host's composition layer, not to this plugin. + */ +export default createFrontendPlugin({ + pluginId: 'openchoreo', + routes: { + catalogEnvironment: rootCatalogEnvironmentRouteRef, + accessControl: accessControlRouteRef, + resourceEnvironments: resourceEnvironmentsRouteRef, + }, + extensions: [ + openChoreoClientApi, + resourceDefinitionEntityContent, + componentDeployEntityContent, + deploymentStatusCard, + runtimeHealthCard, + cellDiagramEntityContent, + projectContentsCard, + deploymentPipelineCard, + namespaceProjectsCard, + namespaceResourcesCard, + resourceDeployEntityContent, + resourceParametersCard, + resourceDeploymentsCard, + consumingComponentsCard, + environmentStatusSummaryCard, + environmentPromotionCard, + environmentDeployedComponentsCard, + environmentGatewayConfigurationCard, + dataplaneStatusCard, + dataplaneEnvironmentsCard, + dataplaneGatewayConfigurationCard, + clusterDataplaneStatusCard, + clusterDataplaneEnvironmentsCard, + clusterDataplaneGatewayConfigurationCard, + workflowPlaneStatusCard, + clusterWorkflowPlaneStatusCard, + observabilityPlaneStatusCard, + observabilityPlaneLinkedPlanesCard, + clusterObservabilityPlaneStatusCard, + clusterObservabilityPlaneLinkedPlanesCard, + deploymentPipelineVisualizationCard, + promotionPathsCard, + componentTypeOverviewCard, + resourceTypeOverviewCard, + traitTypeOverviewCard, + workflowOverviewCard, + componentWorkflowOverviewCard, + ], +}); diff --git a/plugins/platform-engineer-core/package.json b/plugins/platform-engineer-core/package.json index bfd8daa42..f28e68b16 100644 --- a/plugins/platform-engineer-core/package.json +++ b/plugins/platform-engineer-core/package.json @@ -4,6 +4,21 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.tsx" + ], + "package.json": [ + "package.json" + ] + } + }, "repository": { "type": "git", "url": "https://github.com/openchoreo/backstage-plugins.git", @@ -13,6 +28,7 @@ "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts", + "alpha": "dist/alpha.esm.js", "registry": "https://npm.pkg.github.com" }, "backstage": { @@ -37,6 +53,7 @@ "@backstage/catalog-model": "^1.9.0", "@backstage/core-components": "^0.18.10", "@backstage/core-plugin-api": "^1.12.6", + "@backstage/frontend-plugin-api": "^0.17.0", "@backstage/plugin-catalog": "^2.0.5", "@backstage/plugin-catalog-react": "^3.0.0", "@backstage/theme": "^0.7.3", diff --git a/plugins/platform-engineer-core/src/alpha.tsx b/plugins/platform-engineer-core/src/alpha.tsx new file mode 100644 index 000000000..4b96bd435 --- /dev/null +++ b/plugins/platform-engineer-core/src/alpha.tsx @@ -0,0 +1,27 @@ +import { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; + +import { rootRouteRef } from './routes'; + +const platformEngineerDashboardPage = PageBlueprint.make({ + name: 'platform-engineer-dashboard', + params: { + path: '/platform-engineer-view', + routeRef: rootRouteRef, + loader: () => + import('./views/PlatformEngineerDashboardView').then(m => ( + + )), + }, +}); + +/** + * NFS entry point for the Platform Engineer Core plugin. + */ +export default createFrontendPlugin({ + pluginId: 'platform-engineer-core', + routes: { root: rootRouteRef }, + extensions: [platformEngineerDashboardPage], +}); diff --git a/yarn.lock b/yarn.lock index 613b5a613..d103f3b4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1526,19 +1526,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/generator@npm:7.28.3" - dependencies: - "@babel/parser": "npm:^7.28.3" - "@babel/types": "npm:^7.28.2" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/0ff58bcf04f8803dcc29479b547b43b9b0b828ec1ee0668e92d79f9e90f388c28589056637c5ff2fd7bcf8d153c990d29c448d449d852bf9d1bc64753ca462bc - languageName: node - linkType: hard - "@babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" @@ -1565,13 +1552,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-globals@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/helper-globals@npm:7.28.0" - checksum: 10c0/5a0cd0c0e8c764b5f27f2095e4243e8af6fa145daea2b41b53c0c1414fe6ff139e3640f4e2207ae2b3d2153a1abd346f901c26c290ee7cb3881dd922d4ee9232 - languageName: node - linkType: hard - "@babel/helper-globals@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-globals@npm:7.29.7" @@ -1579,17 +1559,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.16.7": - version: 7.27.1 - resolution: "@babel/helper-module-imports@npm:7.27.1" - dependencies: - "@babel/traverse": "npm:^7.27.1" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/e00aace096e4e29290ff8648455c2bc4ed982f0d61dbf2db1b5e750b9b98f318bf5788d75a4f974c151bd318fd549e81dbcab595f46b14b81c12eda3023f51e8 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.29.7": +"@babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-module-imports@npm:7.29.7" dependencies: @@ -1619,13 +1589,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-string-parser@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-string-parser@npm:7.27.1" - checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 - languageName: node - linkType: hard - "@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-string-parser@npm:7.29.7" @@ -1633,7 +1596,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.29.7": +"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-validator-identifier@npm:7.29.7" checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b @@ -1680,17 +1643,6 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/parser@npm:7.28.4" - dependencies: - "@babel/types": "npm:^7.28.4" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/58b239a5b1477ac7ed7e29d86d675cc81075ca055424eba6485872626db2dc556ce63c45043e5a679cd925e999471dba8a3ed4864e7ab1dbf64306ab72c52707 - languageName: node - linkType: hard - "@babel/plugin-syntax-async-generators@npm:^7.8.4": version: 7.8.4 resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" @@ -1894,17 +1846,6 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.27.2": - version: 7.27.2 - resolution: "@babel/template@npm:7.27.2" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/parser": "npm:^7.27.2" - "@babel/types": "npm:^7.27.1" - checksum: 10c0/ed9e9022651e463cc5f2cc21942f0e74544f1754d231add6348ff1b472985a3b3502041c0be62dc99ed2d12cfae0c51394bf827452b98a2f8769c03b87aadc81 - languageName: node - linkType: hard - "@babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" @@ -1916,21 +1857,6 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1": - version: 7.28.4 - resolution: "@babel/traverse@npm:7.28.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.4" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - debug: "npm:^4.3.1" - checksum: 10c0/ee678fdd49c9f54a32e07e8455242390d43ce44887cea6567b233fe13907b89240c377e7633478a32c6cf1be0e17c2f7f3b0c59f0666e39c5074cc47b968489c - languageName: node - linkType: hard - "@babel/traverse@npm:^7.29.7": version: 7.29.7 resolution: "@babel/traverse@npm:7.29.7" @@ -1946,7 +1872,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -1956,16 +1882,6 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.27.1, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/types@npm:7.28.4" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/ac6f909d6191319e08c80efbfac7bd9a25f80cc83b43cd6d82e7233f7a6b9d6e7b90236f3af7400a3f83b576895bcab9188a22b584eb0f224e80e6d4e95f4517 - languageName: node - linkType: hard - "@backstage-community/plugin-github-actions@npm:^0.20.0": version: 0.20.0 resolution: "@backstage-community/plugin-github-actions@npm:0.20.0" @@ -3163,6 +3079,33 @@ __metadata: languageName: node linkType: hard +"@backstage/frontend-app-api@npm:^0.16.3": + version: 0.16.3 + resolution: "@backstage/frontend-app-api@npm:0.16.3" + dependencies: + "@backstage/config": "npm:^1.3.8" + "@backstage/core-app-api": "npm:^1.20.1" + "@backstage/core-plugin-api": "npm:^1.12.6" + "@backstage/errors": "npm:^1.3.1" + "@backstage/filter-predicates": "npm:^0.1.3" + "@backstage/frontend-defaults": "npm:^0.5.2" + "@backstage/frontend-plugin-api": "npm:^0.17.0" + "@backstage/types": "npm:^1.2.2" + "@backstage/version-bridge": "npm:^1.0.12" + lodash: "npm:^4.17.21" + zod: "npm:^3.25.76 || ^4.0.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/1ff0c0708193aa1109acd61f428f783f7fec7c846b3f0ce9def15f9f112d7cfb00da1ad4665a977d6f700db9a31e9a66d75c8c6b991342c43135d06eb66684ef + languageName: node + linkType: hard + "@backstage/frontend-defaults@npm:^0.3.6": version: 0.3.6 resolution: "@backstage/frontend-defaults@npm:0.3.6" @@ -3186,6 +3129,29 @@ __metadata: languageName: node linkType: hard +"@backstage/frontend-defaults@npm:^0.5.2": + version: 0.5.2 + resolution: "@backstage/frontend-defaults@npm:0.5.2" + dependencies: + "@backstage/config": "npm:^1.3.8" + "@backstage/core-components": "npm:^0.18.10" + "@backstage/errors": "npm:^1.3.1" + "@backstage/frontend-app-api": "npm:^0.16.3" + "@backstage/frontend-plugin-api": "npm:^0.17.0" + "@backstage/plugin-app": "npm:^0.4.6" + "@react-hookz/web": "npm:^24.0.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/81c558200ae3dff3472ca974325dbb2147266389360120dcf5a8c575320f5fe295ac237a39ed5cb58ca94d77328604f3ebe4a1a58f99fb451edf216e4a7d3dfe + languageName: node + linkType: hard + "@backstage/frontend-plugin-api@npm:^0.13.3, @backstage/frontend-plugin-api@npm:^0.13.4": version: 0.13.4 resolution: "@backstage/frontend-plugin-api@npm:0.13.4" @@ -3256,6 +3222,42 @@ __metadata: languageName: node linkType: hard +"@backstage/frontend-test-utils@npm:^0.6.0": + version: 0.6.0 + resolution: "@backstage/frontend-test-utils@npm:0.6.0" + dependencies: + "@backstage/config": "npm:^1.3.8" + "@backstage/core-app-api": "npm:^1.20.1" + "@backstage/core-plugin-api": "npm:^1.12.6" + "@backstage/filter-predicates": "npm:^0.1.3" + "@backstage/frontend-app-api": "npm:^0.16.3" + "@backstage/frontend-plugin-api": "npm:^0.17.0" + "@backstage/plugin-app": "npm:^0.4.6" + "@backstage/plugin-app-react": "npm:^0.2.3" + "@backstage/plugin-permission-common": "npm:^0.9.9" + "@backstage/plugin-permission-react": "npm:^0.5.1" + "@backstage/test-utils": "npm:^1.7.18" + "@backstage/types": "npm:^1.2.2" + "@backstage/version-bridge": "npm:^1.0.12" + i18next: "npm:^22.4.15" + zen-observable: "npm:^0.10.0" + zod: "npm:^3.25.76 || ^4.0.0" + peerDependencies: + "@testing-library/react": ^16.0.0 + "@types/jest": "*" + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/jest": + optional: true + "@types/react": + optional: true + checksum: 10c0/022f3e43d0373923f2d363bbdeeed70789a4d6b8464a3f92a2a89ac6f007f0c7d7f140fc04484ae81c44f38251d73a9e92b591d505cdfbf209cb1051530e803f + languageName: node + linkType: hard + "@backstage/integration-aws-node@npm:^0.1.19": version: 0.1.19 resolution: "@backstage/integration-aws-node@npm:0.1.19" @@ -3509,6 +3511,44 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-app@npm:^0.4.6": + version: 0.4.6 + resolution: "@backstage/plugin-app@npm:0.4.6" + dependencies: + "@backstage/core-components": "npm:^0.18.10" + "@backstage/core-plugin-api": "npm:^1.12.6" + "@backstage/filter-predicates": "npm:^0.1.3" + "@backstage/frontend-plugin-api": "npm:^0.17.0" + "@backstage/integration-react": "npm:^1.2.18" + "@backstage/plugin-app-react": "npm:^0.2.3" + "@backstage/plugin-permission-react": "npm:^0.5.1" + "@backstage/theme": "npm:^0.7.3" + "@backstage/types": "npm:^1.2.2" + "@backstage/ui": "npm:^0.15.0" + "@backstage/version-bridge": "npm:^1.0.12" + "@material-ui/core": "npm:^4.9.13" + "@material-ui/icons": "npm:^4.9.1" + "@material-ui/lab": "npm:^4.0.0-alpha.61" + "@react-hookz/web": "npm:^24.0.0" + "@remixicon/react": "npm:>=4.6.0 <4.9.0" + motion: "npm:^12.0.0" + react-aria: "npm:~3.48.0" + react-stately: "npm:~3.46.0" + react-use: "npm:^17.2.4" + zen-observable: "npm:^0.10.0" + zod: "npm:^4.0.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/9be399bb67c1e7551f60c3fffa50419a79d968d51d69a29d489355814b8327745f4cd50510bae710ba6bee0fc197a3a91b9c523469ee09d9aef5c00cfa090f96 + languageName: node + linkType: hard + "@backstage/plugin-auth-backend-module-github-provider@npm:^0.5.3": version: 0.5.3 resolution: "@backstage/plugin-auth-backend-module-github-provider@npm:0.5.3" @@ -8995,6 +9035,7 @@ __metadata: "@backstage/core-components": "npm:^0.18.10" "@backstage/core-plugin-api": "npm:^1.12.6" "@backstage/dev-utils": "npm:^1.1.23" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/plugin-catalog-react": "npm:^3.0.0" "@backstage/test-utils": "npm:^1.7.18" "@backstage/theme": "npm:^0.7.3" @@ -9056,6 +9097,7 @@ __metadata: "@backstage/core-components": "npm:^0.18.10" "@backstage/core-plugin-api": "npm:^1.12.6" "@backstage/dev-utils": "npm:^1.1.23" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/plugin-catalog-react": "npm:^3.0.0" "@backstage/test-utils": "npm:^1.7.18" "@backstage/theme": "npm:^0.7.3" @@ -9154,6 +9196,7 @@ __metadata: "@backstage/core-components": "npm:^0.18.10" "@backstage/core-plugin-api": "npm:^1.12.6" "@backstage/dev-utils": "npm:^1.1.23" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/plugin-catalog-react": "npm:^3.0.0" "@backstage/test-utils": "npm:^1.7.18" "@backstage/theme": "npm:^0.7.3" @@ -9239,6 +9282,7 @@ __metadata: "@backstage/core-components": "npm:^0.18.10" "@backstage/core-plugin-api": "npm:^1.12.6" "@backstage/dev-utils": "npm:^1.1.23" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/plugin-catalog": "npm:^2.0.5" "@backstage/plugin-catalog-react": "npm:^3.0.0" "@backstage/test-utils": "npm:^1.7.18" @@ -9360,6 +9404,7 @@ __metadata: "@backstage/core-plugin-api": "npm:^1.12.6" "@backstage/dev-utils": "npm:^1.1.23" "@backstage/errors": "npm:^1.3.1" + "@backstage/frontend-plugin-api": "npm:^0.17.0" "@backstage/plugin-catalog-react": "npm:^3.0.0" "@backstage/plugin-permission-react": "npm:^0.5.1" "@backstage/test-utils": "npm:^1.7.18" @@ -14981,7 +15026,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.2": +"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.3.4": version: 8.3.5 resolution: "acorn-walk@npm:8.3.5" dependencies: @@ -14990,15 +15035,6 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.3.4": - version: 8.3.4 - resolution: "acorn-walk@npm:8.3.4" - dependencies: - acorn: "npm:^8.11.0" - checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 - languageName: node - linkType: hard - "acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.14.1, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" @@ -15303,10 +15339,17 @@ __metadata: "@backstage/cli": "npm:^0.36.2" "@backstage/config": "npm:^1.3.8" "@backstage/core-app-api": "npm:^1.20.1" + "@backstage/core-compat-api": "npm:^0.5.11" "@backstage/core-components": "npm:^0.18.10" "@backstage/core-plugin-api": "npm:^1.12.6" + "@backstage/frontend-app-api": "npm:^0.16.3" + "@backstage/frontend-defaults": "npm:^0.5.2" + "@backstage/frontend-plugin-api": "npm:^0.17.0" + "@backstage/frontend-test-utils": "npm:^0.6.0" "@backstage/integration-react": "npm:^1.2.18" "@backstage/plugin-api-docs": "npm:^0.14.1" + "@backstage/plugin-app": "npm:^0.4.6" + "@backstage/plugin-app-react": "npm:^0.2.3" "@backstage/plugin-catalog": "npm:^2.0.5" "@backstage/plugin-catalog-common": "npm:^1.1.10" "@backstage/plugin-catalog-graph": "npm:^0.6.4" @@ -21080,6 +21123,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.40.0": + version: 12.40.0 + resolution: "framer-motion@npm:12.40.0" + dependencies: + motion-dom: "npm:^12.40.0" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/a1d26908d6661028fcdba0cf200fca18927a4d4eae0b1e64c37dfb7fdea9da66a8991abd0007079e98687060ba9c83db55620c238bc363106a24ff411d22f533 + languageName: node + linkType: hard + "framer-motion@npm:^6.5.1": version: 6.5.1 resolution: "framer-motion@npm:6.5.1" @@ -27210,6 +27275,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.40.0": + version: 12.40.0 + resolution: "motion-dom@npm:12.40.0" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/79da846a36fd5f6762a0fcfa6e0b7128e4d58f7c07d1467a9f789a9cd0b5adbef9bfbde75760901a13cf2bddd9b31e93e4348a714f570c45ca1e2bfabd22859e + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"motion@npm:^12.0.0": + version: 12.40.0 + resolution: "motion@npm:12.40.0" + dependencies: + framer-motion: "npm:^12.40.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/d0c118ed4829f2999c3ab7eb1ee916df70c65e95d262e951b69d3cea67a74e4a1d12e181badf4180e0d01c1ca8a9b109be4ea5456cad04bb58d4510f071af60f + languageName: node + linkType: hard + "mri@npm:1.1.4": version: 1.1.4 resolution: "mri@npm:1.1.4" @@ -35906,9 +36008,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:*, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.8.0": - version: 8.18.3 - resolution: "ws@npm:8.18.3" +"ws@npm:*, ws@npm:^8.11.0, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.8.0": + version: 8.21.0 + resolution: "ws@npm:8.21.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -35917,7 +36019,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 + checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 languageName: node linkType: hard @@ -35936,21 +36038,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0": - version: 8.21.0 - resolution: "ws@npm:8.21.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 - languageName: node - linkType: hard - "wsl-utils@npm:^0.1.0": version: 0.1.0 resolution: "wsl-utils@npm:0.1.0"