@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
809 lines (713 loc) • 23.6 kB
text/typescript
/*
* 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];
}