UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

809 lines (713 loc) 23.6 kB
/* * Copyright 2025 The Headlamp Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; import { makeCustomResourceClass } from '@kinvolk/headlamp-plugin/lib/lib/k8s/crd'; import { useEffect, useState } from 'react'; import { getUCPApiVersion } from '../config'; /** * Radius CRD API configuration * Using 'v1alpha3' as the current stable version, but Headlamp will fallback to discovery if needed * * Note: Radius deploys the following CRDs: * - deploymentresources.radapp.io - Tracks deployed Radius resources * - recipes.radapp.io - Manages recipe-based deployments * - deploymenttemplates.radapp.io - Manages ARM/Bicep templates * * If Radius updates to v1beta1 or v1, update the version here or use multiple entries: * [{ group: 'radapp.io', version: 'v1' }, { group: 'radapp.io', version: 'v1alpha3' }] */ const RADIUS_API_INFO = [{ group: 'radapp.io', version: 'v1alpha3' }]; /** * Radius DeploymentResource CRD class * DeploymentResources track all Radius resource deployments in Kubernetes * This is the primary CRD for querying Radius applications, environments, and other resources */ export const DeploymentResourceClass = makeCustomResourceClass({ apiInfo: RADIUS_API_INFO, isNamespaced: true, singularName: 'deploymentresource', pluralName: 'deploymentresources', }); /** * Radius Recipe CRD class * Recipes represent recipe-based resource deployments */ export const RecipeClass = makeCustomResourceClass({ apiInfo: RADIUS_API_INFO, isNamespaced: true, singularName: 'recipe', pluralName: 'recipes', }); /** * Legacy aliases for backward compatibility * Note: These may not work if the specific CRDs don't exist in your cluster * Consider using DeploymentResourceClass and filtering by resource type instead */ export const ApplicationClass = makeCustomResourceClass({ apiInfo: RADIUS_API_INFO, isNamespaced: true, singularName: 'application', pluralName: 'applications', }); export const EnvironmentClass = makeCustomResourceClass({ apiInfo: RADIUS_API_INFO, isNamespaced: true, singularName: 'environment', pluralName: 'environments', }); export const ContainerClass = makeCustomResourceClass({ apiInfo: RADIUS_API_INFO, isNamespaced: true, singularName: 'container', pluralName: 'containers', }); /** * Type definitions for Radius CRD resources */ export interface RadiusResource { apiVersion: string; kind: string; metadata: { name: string; namespace?: string; creationTimestamp?: string; labels?: Record<string, string>; annotations?: Record<string, string>; }; spec?: any; status?: any; } /** * RadiusDeploymentResource represents a Radius resource deployment * This CRD tracks Applications.Core resources like applications, environments, containers */ export interface RadiusDeploymentResource extends RadiusResource { kind: 'DeploymentResource'; spec: { id: string; // Radius resource ID like "/planes/radius/local/resourceGroups/default/providers/Applications.Core/applications/myapp" }; status?: { id?: string; phrase?: 'Provisioning' | 'Updating' | 'Ready' | 'Failed' | 'Deleting' | 'Deleted'; scope?: string; resource?: string; operation?: { resumeToken?: string; operationKind?: string; }; }; } /** * RadiusRecipe represents a Recipe deployment */ export interface RadiusRecipe extends RadiusResource { kind: 'Recipe'; spec: { type: string; // ex: 'Applications.Datastores/redisCaches' secretName?: string; environment?: string; application?: string; }; status?: { phrase?: 'Updating' | 'Ready' | 'Failed' | 'Deleting'; resource?: string; scope?: string; observedGeneration?: number; operation?: { resumeToken?: string; operationKind?: string; }; }; } /** * Legacy type definitions for backward compatibility */ export interface RadiusApplication extends RadiusResource { kind: 'Application'; spec: { environment?: string; }; status?: { compute?: { kind?: string; resourceProvisioning?: string; }; }; } export interface RadiusEnvironment extends RadiusResource { kind: 'Environment'; spec: { compute?: { kind?: string; namespace?: string; }; providers?: { azure?: { scope?: string; }; }; }; status?: { compute?: { kind?: string; resourceProvisioning?: string; }; }; } /** * Helper function to safely get resource status */ export function getResourceStatus(resource: RadiusResource): string { if ('status' in resource && resource.status) { // Check for DeploymentResource status if ('phrase' in resource.status) { return resource.status.phrase; } // Check for legacy status format if (resource.status.compute?.resourceProvisioning) { return resource.status.compute.resourceProvisioning; } } return 'Unknown'; } /** * Helper function to get environment from application */ export function getApplicationEnvironment(app: RadiusApplication): string { return app.spec?.environment || 'N/A'; } /** * Helper function to parse Radius resource ID from DeploymentResource * Extracts resource type and name from IDs like: * "/planes/radius/local/resourceGroups/default/providers/Applications.Core/applications/myapp" */ export function parseRadiusResourceId(resourceId: string): { resourceType: string; resourceName: string; resourceProvider: string; } | null { if (!resourceId) return null; // Match pattern: /providers/{provider}/{type}/{name} const regex = /\/providers\/([^/]+)\/([^/]+)\/([^/]+)$/; const match = regex.exec(resourceId); if (!match) return null; return { resourceProvider: match[1], // ex "Applications.Core" resourceType: match[2], // ex "applications" resourceName: match[3], // ex "myapp" }; } /** * Helper function to filter DeploymentResources by resource type */ export function filterDeploymentResourcesByType( resources: RadiusDeploymentResource[], resourceType: string // ex "applications", "environments", "containers" ): RadiusDeploymentResource[] { return resources.filter(resource => { const parsed = parseRadiusResourceId(resource.spec?.id); return parsed?.resourceType === resourceType; }); } /** * UCP API types and interfaces * Radius exposes its API through Kubernetes API Aggregation Layer * Base path: /apis/api.ucp.dev/v1alpha3/planes/radius/local/providers/{provider}/{resourceType} */ export interface UCPApplication { id: string; // ex "/planes/radius/local/resourceGroups/default/providers/Applications.Core/applications/eshop" name: string; type: string; // "Applications.Core/applications" location: string; properties: { environment: string; provisioningState?: string; status?: { compute?: { kind?: string; namespace?: string; }; }; }; systemData?: { createdAt?: string; createdBy?: string; createdByType?: string; lastModifiedAt?: string; lastModifiedBy?: string; lastModifiedByType?: string; }; tags?: Record<string, string>; } export interface UCPEnvironment { id: string; name: string; type: string; // "Applications.Core/environments" location: string; properties: { provisioningState?: string; compute?: { kind?: string; namespace?: string; resourceId?: string; }; providers?: { azure?: { scope?: string; }; }; recipes?: { [resourceType: string]: { [recipeName: string]: { templateKind: string; templatePath: string; templateVersion?: string; plainHttp?: boolean; parameters?: Record<string, any>; }; }; }; }; systemData?: { createdAt?: string; createdBy?: string; createdByType?: string; lastModifiedAt?: string; lastModifiedBy?: string; lastModifiedByType?: string; }; tags?: Record<string, string>; } export interface UCPListResponse<T> { value: T[]; nextLink?: string; } /** * Generic UCP Resource interface * All Radius resources share this common structure */ export interface UCPResource { id: string; name: string; type: string; location: string; properties: { application?: string; environment?: string; provisioningState?: string; status?: { compute?: { kind?: string; namespace?: string; }; }; [key: string]: any; // Allow additional resource-specific properties }; systemData?: { createdAt?: string; createdBy?: string; createdByType?: string; lastModifiedAt?: string; lastModifiedBy?: string; lastModifiedByType?: string; }; tags?: Record<string, string>; } /** * Hook to fetch Radius applications from UCP API * Uses Kubernetes API Aggregation to access Radius UCP endpoints */ export function useRadiusApplications(): [UCPApplication[] | null, Error | null, boolean] { const [applications, setApplications] = useState<UCPApplication[] | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; async function fetchApplications() { try { // Get the configured UCP API version from plugin settings const apiVersion = getUCPApiVersion(); // Construct the UCP API path through Kubernetes API Aggregation // Note: UCP uses api-version as a query parameter (not a standard K8s parameter) const path = `/apis/api.ucp.dev/v1alpha3/planes/radius/local/providers/Applications.Core/applications?api-version=${apiVersion}`; // Use Headlamp's request function which handles cluster context and authentication const data: UCPListResponse<UCPApplication> = await ApiProxy.request( path, {}, // request params true, // autoLogoutOnAuthError true // useCluster ); if (mounted) { setApplications(data.value || []); setError(null); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); setApplications(null); } } finally { if (mounted) { setLoading(false); } } } fetchApplications(); return () => { mounted = false; }; }, []); return [applications, error, loading]; } /** * Hook to fetch Radius environments from UCP API */ export function useRadiusEnvironments(): [UCPEnvironment[] | null, Error | null, boolean] { const [environments, setEnvironments] = useState<UCPEnvironment[] | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; async function fetchEnvironments() { try { // Get the configured UCP API version from plugin settings const apiVersion = getUCPApiVersion(); // Construct the UCP API path through Kubernetes API Aggregation // Note: UCP uses api-version as a query parameter (not a standard K8s parameter) const path = `/apis/api.ucp.dev/v1alpha3/planes/radius/local/providers/Applications.Core/environments?api-version=${apiVersion}`; // Use Headlamp's request function which handles cluster context and authentication const data: UCPListResponse<UCPEnvironment> = await ApiProxy.request( path, {}, // request params true, // autoLogoutOnAuthError true // useCluster ); if (mounted) { setEnvironments(data.value || []); setError(null); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); setEnvironments(null); } } finally { if (mounted) { setLoading(false); } } } fetchEnvironments(); return () => { mounted = false; }; }, []); return [environments, error, loading]; } /** * Hook to fetch all Radius resources from multiple resource types and providers * Fetches containers, gateways, and other common resource types from various providers */ export function useRadiusResources(): [UCPResource[] | null, Error | null, boolean] { const [resources, setResources] = useState<UCPResource[] | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; async function fetchResources() { try { const apiVersion = getUCPApiVersion(); // Define resource types organized by provider // Applications.Core - Core application resources const coreResourceTypes = [ 'containers', 'gateways', 'httpRoutes', 'secretStores', 'volumes', 'extenders', ]; // Applications.Datastores - Database and cache resources const datastoreResourceTypes = ['mongoDatabases', 'redisCaches', 'sqlDatabases']; // Applications.Messaging - Messaging resources const messagingResourceTypes = ['rabbitMQQueues']; // Applications.Dapr - Dapr component resources const daprResourceTypes = ['pubSubBrokers', 'secretStores', 'stateStores']; // Build promises for all resource types across all providers const promises: Promise<UCPResource[]>[] = []; // Helper function to fetch resources for a provider const fetchProviderResources = async ( provider: string, resourceType: string ): Promise<UCPResource[]> => { try { const path = `/apis/api.ucp.dev/v1alpha3/planes/radius/local/providers/${provider}/${resourceType}?api-version=${apiVersion}`; const data: UCPListResponse<UCPResource> = await ApiProxy.request(path, {}, true, true); return data.value || []; } catch (err) { const message = `Failed to fetch ${provider}/${resourceType}: ${ err instanceof Error ? err.message : String(err) }`; console.warn(message); return []; } }; // Fetch Applications.Core resources coreResourceTypes.forEach(resourceType => { promises.push(fetchProviderResources('Applications.Core', resourceType)); }); // Fetch Applications.Datastores resources datastoreResourceTypes.forEach(resourceType => { promises.push(fetchProviderResources('Applications.Datastores', resourceType)); }); // Fetch Applications.Messaging resources messagingResourceTypes.forEach(resourceType => { promises.push(fetchProviderResources('Applications.Messaging', resourceType)); }); // Fetch Applications.Dapr resources daprResourceTypes.forEach(resourceType => { promises.push(fetchProviderResources('Applications.Dapr', resourceType)); }); const results = await Promise.all(promises); const allResources = results.flat(); if (allResources.length === 0) { throw new Error( 'Failed to fetch Radius resources from all configured providers and resource types.' ); } if (mounted) { setResources(allResources); setError(null); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); setResources(null); } } finally { if (mounted) { setLoading(false); } } } fetchResources(); return () => { mounted = false; }; }, []); return [resources, error, loading]; } /** * Hook to fetch resources for a specific application * Filters resources that belong to the specified application */ export function useApplicationResources( applicationId: string ): [UCPResource[] | null, Error | null, boolean] { const [resources, allResourcesError, loading] = useRadiusResources(); const [filteredResources, setFilteredResources] = useState<UCPResource[] | null>(null); const [error, setError] = useState<Error | null>(null); useEffect(() => { if (resources) { const appResources = resources.filter(resource => { if (!resource.properties.application) { return false; } if (resource.properties.application === applicationId) { return true; } const appIdParts = applicationId.split('/'); const appName = appIdParts.at(-1); const resourceAppParts = resource.properties.application.split('/'); const resourceAppName = resourceAppParts.at(-1); return appName === resourceAppName; }); setFilteredResources(appResources); setError(null); } else if (allResourcesError) { setError(allResourcesError); setFilteredResources(null); } }, [resources, allResourcesError, applicationId]); return [filteredResources, error, loading]; } /** * Interface for Radius Resource Type */ export interface UCPResourceType { id: string; name: string; type: string; apiVersions: { [version: string]: any; }; capabilities: string[]; description?: string; resourceProvider?: string; } /** * Interface for Radius Resource Provider */ interface UCPResourceProvider { name: string; locations?: { [location: string]: any; }; resourceTypes: { [key: string]: { apiVersions: { [version: string]: any; }; capabilities: string[]; description?: string; }; }; } /** * Hook to fetch Radius resource types from UCP API * Fetches all providers and extracts resource types from each provider */ export function useRadiusResourceTypes(): [UCPResourceType[] | null, Error | null, boolean] { const [resourceTypes, setResourceTypes] = useState<UCPResourceType[] | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; async function fetchResourceTypes() { try { // Get the configured UCP API version from plugin settings const apiVersion = getUCPApiVersion(); // Query all providers - each provider includes its resource types const path = `/apis/api.ucp.dev/v1alpha3/planes/radius/local/providers?api-version=${apiVersion}`; const data: UCPListResponse<UCPResourceProvider> = await ApiProxy.request( path, {}, // request params true, // autoLogoutOnAuthError true // useCluster ); // Extract resource types from each provider const allResourceTypes: UCPResourceType[] = []; (data.value || []).forEach(provider => { const providerName = provider.name; const resourceTypesObj = provider.resourceTypes || {}; // Convert resource types object to array and add provider name Object.entries(resourceTypesObj).forEach(([typeName, resourceType]) => { allResourceTypes.push({ id: `/planes/radius/local/providers/${providerName}/resourcetypes/${typeName}`, name: typeName, type: `${providerName}/${typeName}`, apiVersions: resourceType.apiVersions || {}, capabilities: resourceType.capabilities || [], description: resourceType.description, resourceProvider: providerName, }); }); }); if (mounted) { setResourceTypes(allResourceTypes); setError(null); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); setResourceTypes(null); } } finally { if (mounted) { setLoading(false); } } } fetchResourceTypes(); return () => { mounted = false; }; }, []); return [resourceTypes, error, loading]; } /** * Hook to fetch a specific Radius resource type detail * Uses the same providers endpoint as the list to ensure we get complete data including schemas * @param provider - The resource provider (e.g., "Applications.Core") * @param typeName - The resource type name (e.g., "applications") */ export function useRadiusResourceTypeDetail( provider: string, typeName: string ): [UCPResourceType | null, Error | null, boolean] { const [resourceType, setResourceType] = useState<UCPResourceType | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let mounted = true; async function fetchResourceTypeDetail() { try { const apiVersion = getUCPApiVersion(); // Use the same providers endpoint as the list const path = `/apis/api.ucp.dev/v1alpha3/planes/radius/local/providers?api-version=${apiVersion}`; const data: UCPListResponse<UCPResourceProvider> = await ApiProxy.request( path, {}, // request params true, // autoLogoutOnAuthError true // useCluster ); // Find the specific provider and resource type const targetProvider = data.value?.find(p => p.name === provider); if (!targetProvider) { throw new Error(`Provider ${provider} not found`); } const resourceTypesObj = targetProvider.resourceTypes || {}; const targetResourceType = resourceTypesObj[typeName]; if (!targetResourceType) { throw new Error(`Resource type ${typeName} not found in provider ${provider}`); } // Construct the full resource type object const fullResourceType: UCPResourceType = { id: `/planes/radius/local/providers/${provider}/resourcetypes/${typeName}`, name: typeName, type: `${provider}/${typeName}`, apiVersions: targetResourceType.apiVersions || {}, capabilities: targetResourceType.capabilities || [], description: targetResourceType.description, resourceProvider: provider, }; if (mounted) { setResourceType(fullResourceType); setError(null); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); setResourceType(null); } } finally { if (mounted) { setLoading(false); } } } if (provider && typeName) { fetchResourceTypeDetail(); } else { setLoading(false); setError(new Error('Provider and type name are required')); } return () => { mounted = false; }; }, [provider, typeName]); return [resourceType, error, loading]; }