feat(frontend): add Helm chart browser, monitoring, chart-references and values templates pages

Add new frontend pages for the multi-tenant OCDP platform:

- Charts page (/charts): Browse Harbor OCI registries to list Helm chart repositories
  and versions, with deploy modal to launch charts on selected clusters
- Monitoring page (/monitoring): Display cluster metrics (CPU/Memory/GPU usage)
  and per-node details with resource utilization bars
- Chart References page (/chart-references): CRUD for chart metadata references
- Values Templates page (/templates): CRUD for Helm values templates with version
  history and rollback support
- Sidebar: Add Charts navigation, update Storage and Templates links
- api.ts: Add all API client functions (clusterApi, registryApi, instanceApi,
  monitoringApi, storageApi, chartReferenceApi, valuesTemplateApi,
  workspaceApi, userApi) with full TypeScript types

Note: deploy flow and values template rollback not yet end-to-end tested.
This commit is contained in:
Ivan087
2026-04-15 16:59:31 +08:00
parent c5e51ed069
commit 29d0310f03
283 changed files with 24658 additions and 36038 deletions

View File

@ -1,99 +0,0 @@
import Axios from "axios";
import type { AxiosRequestConfig } from "axios";
import { keysToCamel, keysToSnake } from "@/shared/utils/case-converter";
/**
* Axios instance for Orval-generated API client
*
* Features:
* - Configurable baseURL from environment variable
* - Auth token management
* - Request cancellation support
*/
const FALLBACK_API_BASE = '/api/v1';
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
export const AXIOS_INSTANCE = Axios.create({
// Default to /api/v1 so local dev still hits the proxy when no env is provided
baseURL: configuredBaseUrl && configuredBaseUrl.length > 0 ? configuredBaseUrl : FALLBACK_API_BASE,
});
const isTransformablePayload = (payload: unknown) => {
if (payload === null || payload === undefined) {
return false;
}
if (payload instanceof FormData || payload instanceof Blob || payload instanceof ArrayBuffer) {
return false;
}
return typeof payload === "object";
};
AXIOS_INSTANCE.interceptors.request.use((config) => {
if (isTransformablePayload(config.data)) {
config.data = keysToSnake(config.data);
}
if (isTransformablePayload(config.params)) {
config.params = keysToSnake(config.params);
}
return config;
});
AXIOS_INSTANCE.interceptors.response.use(
(response) => {
if (isTransformablePayload(response?.data)) {
response.data = keysToCamel(response.data);
}
return response;
},
(error) => {
if (isTransformablePayload(error?.response?.data)) {
error.response.data = keysToCamel(error.response.data);
}
return Promise.reject(error);
}
);
/**
* Custom axios instance for Orval
* Provides request cancellation support
*/
export const customAxiosInstance = <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig
): Promise<T> => {
const source = Axios.CancelToken.source();
const promise = AXIOS_INSTANCE({
...config,
...options,
cancelToken: source.token,
}).then(({ data }) => data);
// @ts-ignore
promise.cancel = () => {
source.cancel('Query was cancelled');
};
return promise as any;
};
/**
* Set authentication token for all requests
*
* @param token JWT token or null to remove
*
* @example
* import { setAuthToken } from '@/api/axios-mutator';
*
* // Set token
* setAuthToken('your-jwt-token');
*
* // Remove token
* setAuthToken(null);
*/
export function setAuthToken(token: string | null) {
if (token) {
AXIOS_INSTANCE.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete AXIOS_INSTANCE.defaults.headers.common['Authorization'];
}
}

View File

@ -1,439 +0,0 @@
/**
* Generated by orval v7.3.0 🍺
* Do not edit manually.
* OCDP Backend API
* OCDP (Open Cloud Development Platform) Backend API
RESTful API for managing Kubernetes clusters, OCI registries, and Helm deployments.
* OpenAPI spec version: 1.0
*/
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaPathParameters = {
registryId: string,
repositoryName: string,
reference: string,
}
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferencePathParameters = {
registryId: string,
repositoryName: string,
reference: string,
}
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsParams = {
/**
* 过滤 Artifact 类型 (all, chart, image, other)
*/
media_type?: string;
};
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters = {
registryId: string,
repositoryName: string,
}
export type GetRegistriesRegistryIdRepositoriesPathParameters = {
registryId: string,
}
export type GetRegistriesRegistryIdHealthPathParameters = {
registryId: string,
}
export type PutRegistriesRegistryIdPathParameters = {
registryId: string,
}
export type GetRegistriesRegistryIdPathParameters = {
registryId: string,
}
export type DeleteRegistriesRegistryIdPathParameters = {
registryId: string,
}
export type GetMonitoringClustersClusterIdNodesPathParameters = {
clusterId: string,
}
export type GetMonitoringClustersClusterIdPathParameters = {
clusterId: string,
}
export type GetClustersClusterIdInstancesInstanceIdEntriesPathParameters = {
clusterId: string,
instanceId: string,
}
export type PutClustersClusterIdInstancesInstanceIdPathParameters = {
clusterId: string,
instanceId: string,
}
export type GetClustersClusterIdInstancesInstanceIdPathParameters = {
clusterId: string,
instanceId: string,
}
export type DeleteClustersClusterIdInstancesInstanceIdPathParameters = {
clusterId: string,
instanceId: string,
}
export type PostClustersClusterIdInstancesPathParameters = {
clusterId: string,
}
export type GetClustersClusterIdInstancesPathParameters = {
clusterId: string,
}
export type GetClustersClusterIdHealthPathParameters = {
clusterId: string,
}
export type PutClustersClusterIdPathParameters = {
clusterId: string,
}
export type GetClustersClusterIdPathParameters = {
clusterId: string,
}
export type DeleteClustersClusterIdPathParameters = {
clusterId: string,
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoValuesSchemaResponse {
schema?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUserResponse {
createdAt?: string;
email?: string;
id?: string;
updatedAt?: string;
username?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest {
description?: string;
insecure?: boolean;
name?: string;
password?: string;
url?: string;
username?: string;
}
export type GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequestValues = { [key: string]: unknown };
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest {
description?: string;
values?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequestValues;
valuesYaml?: string;
version?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest {
/** Base64 CA data (also accepts legacy field "ca_data") */
caData?: string;
/** Base64 client certificate (also accepts legacy field "cert_data") */
certData?: string;
description?: string;
host?: string;
/** Base64 client key (also accepts legacy field "key_data") */
keyData?: string;
name?: string;
token?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoTagResponse {
mediaType?: string;
/** Repository name */
repositoryName?: string;
/** Artifact size (bytes) */
size?: number;
/** Tag name (e.g. "1.0.0", "latest") */
tag?: string;
/** Artifact type: chart, image, other */
type?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRepositoryListResponse {
/** Whether _catalog API is supported */
catalogSupported?: boolean;
/** User-friendly message */
message?: string;
registryId?: string;
registryUrl?: string;
repositories?: string[];
/** Data source: "catalog" | "preconfigured" | "unavailable" */
source?: string;
total?: number;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse {
createdAt?: string;
description?: string;
/** 是否已设置密码 */
hasPassword?: boolean;
id?: string;
insecure?: boolean;
name?: string;
/** 脱敏显示(••••••••) */
password?: string;
updatedAt?: string;
url?: string;
/** 明文返回用户名(不敏感) */
username?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryHealthResponse {
healthy?: boolean;
message?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest {
/** @minLength 6 */
password: string;
username: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest {
refreshToken: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoNodeMetricsResponse {
age?: string;
containerRuntime?: string;
cpuAllocatable?: string;
cpuCapacity?: string;
cpuPercent?: number;
cpuUsage?: string;
gpuCapacity?: number;
gpuPercent?: number;
gpuType?: string;
gpuUsage?: number;
kernelVersion?: string;
kubeletVersion?: string;
memoryAllocatable?: string;
memoryCapacity?: string;
memoryPercent?: number;
memoryUsage?: string;
nodeName?: string;
osImage?: string;
podCount?: number;
role?: string;
status?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoMonitoringSummaryResponse {
errorClusters?: number;
healthyClusters?: number;
lastUpdate?: string;
totalClusters?: number;
totalNodes?: number;
totalPods?: number;
warningClusters?: number;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest {
password: string;
username: string;
}
export type GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseValues = { [key: string]: unknown };
/**
* 实例当前状态
*/
export type GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus = typeof GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus[keyof typeof GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus = {
deployed: 'deployed',
uninstalled: 'uninstalled',
superseded: 'superseded',
failed: 'failed',
'pending-install': 'pending-install',
'pending-upgrade': 'pending-upgrade',
'pending-rollback': 'pending-rollback',
'pending-delete': 'pending-delete',
unknown: 'unknown',
} as const;
/**
* 最后一次操作类型
*/
export type GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation = typeof GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation[keyof typeof GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation = {
'': '',
install: 'install',
upgrade: 'upgrade',
rollback: 'rollback',
delete: 'delete',
sync: 'sync',
} as const;
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse {
chart?: string;
clusterId?: string;
createdAt?: string;
description?: string;
id?: string;
/** 最近一次错误信息 */
lastError?: string;
/** 最后一次操作类型 */
lastOperation?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation;
name?: string;
namespace?: string;
registryId?: string;
repository?: string;
revision?: number;
/** 实例当前状态 */
status?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus;
/** 状态说明 */
statusReason?: string;
updatedAt?: string;
values?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseValues;
version?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceListResponse {
instances?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse[];
total?: number;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryTLSResponse {
hosts?: string[];
secretName?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryPortResponse {
name?: string;
nodePort?: number;
port?: number;
protocol?: string;
targetPort?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryResponse {
clusterIP?: string;
externalIPs?: string[];
hosts?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryHostResponse[];
kind?: string;
loadBalancerIngress?: string[];
name?: string;
namespace?: string;
ports?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryPortResponse[];
tls?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryTLSResponse[];
type?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryPathResponse {
path?: string;
serviceName?: string;
servicePort?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryHostResponse {
host?: string;
paths?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryPathResponse[];
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoErrorResponse {
code?: number;
error?: string;
message?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest {
description?: string;
insecure?: boolean;
name: string;
password?: string;
url: string;
username?: string;
}
export type GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequestValues = { [key: string]: unknown };
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest {
description?: string;
name: string;
namespace: string;
/** Registry identifier (also accepts legacy field "registry_id") */
registryId: string;
repository: string;
tag: string;
values?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequestValues;
valuesYaml?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest {
/** Base64 CA data (also accepts legacy field "ca_data") */
caData?: string;
/** Base64 client certificate (also accepts legacy field "cert_data") */
certData?: string;
description?: string;
host: string;
/** Base64 client key (also accepts legacy field "key_data") */
keyData?: string;
name: string;
token?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse {
/** 脱敏数据(仅用于前端显示,实际值为掩码) */
caData?: string;
/** 脱敏显示(••••••••) */
certData?: string;
createdAt?: string;
description?: string;
/** 认证配置状态(不返回实际证书数据,仅返回是否已配置) */
hasCaData?: boolean;
hasCertData?: boolean;
hasKeyData?: boolean;
hasToken?: boolean;
host?: string;
id?: string;
/** 脱敏显示(••••••••) */
keyData?: string;
name?: string;
/** 脱敏显示(••••••••) */
token?: string;
updatedAt?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterMetricsResponse {
clusterId?: string;
clusterName?: string;
cpuUsage?: number;
gpuUsage?: number;
lastCheck?: string;
maxNodeCpu?: string;
maxNodeCpuUsage?: number;
maxNodeGpu?: number;
maxNodeGpuUsage?: number;
maxNodeMemory?: string;
maxNodeMemUsage?: number;
memoryUsage?: number;
nodeCount?: number;
nodes?: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoNodeMetricsResponse[];
podCount?: number;
status?: string;
totalCpu?: string;
totalGpu?: number;
totalMemory?: string;
uptime?: string;
usedCpu?: string;
usedGpu?: number;
usedMemory?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterHealthResponse {
healthy?: boolean;
message?: string;
version?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoAuthResponse {
accessToken?: string;
refreshToken?: string;
userId?: string;
username?: string;
}
export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoArtifactResponse {
createdAt?: string;
digest?: string;
repositoryName?: string;
size?: number;
tag?: string;
/** chart | image | other */
type?: string;
}

View File

@ -1,474 +0,0 @@
/**
* Generated by orval v7.3.0 🍺
* Do not edit manually.
* OCDP Backend API
* OCDP (Open Cloud Development Platform) Backend API
RESTful API for managing Kubernetes clusters, OCI registries, and Helm deployments.
* OpenAPI spec version: 1.0
*/
import type {
DeleteClustersClusterIdInstancesInstanceIdPathParameters,
DeleteClustersClusterIdPathParameters,
DeleteRegistriesRegistryIdPathParameters,
GetClustersClusterIdHealthPathParameters,
GetClustersClusterIdInstancesInstanceIdEntriesPathParameters,
GetClustersClusterIdInstancesInstanceIdPathParameters,
GetClustersClusterIdInstancesPathParameters,
GetClustersClusterIdPathParameters,
GetMonitoringClustersClusterIdNodesPathParameters,
GetMonitoringClustersClusterIdPathParameters,
GetRegistriesRegistryIdHealthPathParameters,
GetRegistriesRegistryIdPathParameters,
GetRegistriesRegistryIdRepositoriesPathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsParams,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferencePathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaPathParameters,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoArtifactResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoAuthResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterHealthResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterMetricsResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceListResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoMonitoringSummaryResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoNodeMetricsResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryHealthResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRepositoryListResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoTagResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUserResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoValuesSchemaResponse,
PostClustersClusterIdInstancesPathParameters,
PutClustersClusterIdInstancesInstanceIdPathParameters,
PutClustersClusterIdPathParameters,
PutRegistriesRegistryIdPathParameters
} from './api.schemas'
import { customAxiosInstance } from '../axios-mutator';
type SecondParameter<T extends (...args: any) => any> = Parameters<T>[1];
/**
* 使用用户名和密码获取访问令牌
* @summary 用户登录
*/
export const postAuthLogin = (
githubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoAuthResponse>(
{url: `/auth/login`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest
},
options);
}
/**
* 使用刷新令牌获取新的访问令牌
* @summary 刷新访问令牌
*/
export const postAuthRefresh = (
githubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoAuthResponse>(
{url: `/auth/refresh`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest
},
options);
}
/**
* 创建一个新的后台用户
* @summary 用户注册
*/
export const postAuthRegister = (
githubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUserResponse>(
{url: `/auth/register`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest
},
options);
}
/**
* @summary 列出所有集群
*/
export const getClusters = (
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse[]>(
{url: `/clusters`, method: 'GET'
},
options);
}
/**
* 创建一个新的 Kubernetes 集群配置
* @summary 创建集群
*/
export const postClusters = (
githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse>(
{url: `/clusters`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest
},
options);
}
/**
* @summary 删除集群
*/
export const deleteClustersClusterId = (
{ clusterId }: DeleteClustersClusterIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<string>(
{url: `/clusters/${clusterId}`, method: 'DELETE'
},
options);
}
/**
* @summary 获取集群详情
*/
export const getClustersClusterId = (
{ clusterId }: GetClustersClusterIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse>(
{url: `/clusters/${clusterId}`, method: 'GET'
},
options);
}
/**
* @summary 更新集群
*/
export const putClustersClusterId = (
{ clusterId }: PutClustersClusterIdPathParameters,
githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse>(
{url: `/clusters/${clusterId}`, method: 'PUT',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest
},
options);
}
/**
* @summary 获取集群健康状态
*/
export const getClustersClusterIdHealth = (
{ clusterId }: GetClustersClusterIdHealthPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterHealthResponse>(
{url: `/clusters/${clusterId}/health`, method: 'GET'
},
options);
}
/**
* @summary 列出实例
*/
export const getClustersClusterIdInstances = (
{ clusterId }: GetClustersClusterIdInstancesPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceListResponse>(
{url: `/clusters/${clusterId}/instances`, method: 'GET'
},
options);
}
/**
* 在指定集群上部署一个 artifact
* @summary 创建实例
*/
export const postClustersClusterIdInstances = (
{ clusterId }: PostClustersClusterIdInstancesPathParameters,
githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse>(
{url: `/clusters/${clusterId}/instances`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest
},
options);
}
/**
* @summary 删除实例
*/
export const deleteClustersClusterIdInstancesInstanceId = (
{ clusterId, instanceId }: DeleteClustersClusterIdInstancesInstanceIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<string>(
{url: `/clusters/${clusterId}/instances/${instanceId}`, method: 'DELETE'
},
options);
}
/**
* @summary 获取实例详情
*/
export const getClustersClusterIdInstancesInstanceId = (
{ clusterId, instanceId }: GetClustersClusterIdInstancesInstanceIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse>(
{url: `/clusters/${clusterId}/instances/${instanceId}`, method: 'GET'
},
options);
}
/**
* @summary 更新实例
*/
export const putClustersClusterIdInstancesInstanceId = (
{ clusterId, instanceId }: PutClustersClusterIdInstancesInstanceIdPathParameters,
githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse>(
{url: `/clusters/${clusterId}/instances/${instanceId}`, method: 'PUT',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest
},
options);
}
/**
* @summary 获取实例 Service/Ingress 入口
*/
export const getClustersClusterIdInstancesInstanceIdEntries = (
{ clusterId, instanceId }: GetClustersClusterIdInstancesInstanceIdEntriesPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryResponse[]>(
{url: `/clusters/${clusterId}/instances/${instanceId}/entries`, method: 'GET'
},
options);
}
/**
* @summary 列出集群监控
*/
export const getMonitoringClusters = (
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterMetricsResponse[]>(
{url: `/monitoring/clusters`, method: 'GET'
},
options);
}
/**
* @summary 获取集群监控
*/
export const getMonitoringClustersClusterId = (
{ clusterId }: GetMonitoringClustersClusterIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterMetricsResponse>(
{url: `/monitoring/clusters/${clusterId}`, method: 'GET'
},
options);
}
/**
* @summary 获取节点指标
*/
export const getMonitoringClustersClusterIdNodes = (
{ clusterId }: GetMonitoringClustersClusterIdNodesPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoNodeMetricsResponse[]>(
{url: `/monitoring/clusters/${clusterId}/nodes`, method: 'GET'
},
options);
}
/**
* @summary 获取监控汇总
*/
export const getMonitoringSummary = (
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoMonitoringSummaryResponse>(
{url: `/monitoring/summary`, method: 'GET'
},
options);
}
/**
* @summary 列出所有 Registries
*/
export const getRegistries = (
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse[]>(
{url: `/registries`, method: 'GET'
},
options);
}
/**
* 新增 OCI Registry 配置
* @summary 创建 Registry
*/
export const postRegistries = (
githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse>(
{url: `/registries`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest
},
options);
}
/**
* @summary 删除 Registry
*/
export const deleteRegistriesRegistryId = (
{ registryId }: DeleteRegistriesRegistryIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<string>(
{url: `/registries/${registryId}`, method: 'DELETE'
},
options);
}
/**
* @summary 获取 Registry
*/
export const getRegistriesRegistryId = (
{ registryId }: GetRegistriesRegistryIdPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse>(
{url: `/registries/${registryId}`, method: 'GET'
},
options);
}
/**
* @summary 更新 Registry
*/
export const putRegistriesRegistryId = (
{ registryId }: PutRegistriesRegistryIdPathParameters,
githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest: GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse>(
{url: `/registries/${registryId}`, method: 'PUT',
headers: {'Content-Type': 'application/json', },
data: githubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest
},
options);
}
/**
* @summary 检查 Registry 健康
*/
export const getRegistriesRegistryIdHealth = (
{ registryId }: GetRegistriesRegistryIdHealthPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryHealthResponse>(
{url: `/registries/${registryId}/health`, method: 'GET'
},
options);
}
/**
* 列出指定 Registry 中的所有 Repository
* @summary 列出 Registry 中的所有 Repositories
*/
export const getRegistriesRegistryIdRepositories = (
{ registryId }: GetRegistriesRegistryIdRepositoriesPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRepositoryListResponse>(
{url: `/registries/${registryId}/repositories`, method: 'GET'
},
options);
}
/**
* 列出指定 Repository 中的所有 Artifact支持按类型过滤
* @summary 列出 Repository 中的所有 Artifacts
*/
export const getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts = (
{ registryId, repositoryName }: GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters,
params?: GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsParams,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoTagResponse[]>(
{url: `/registries/${registryId}/repositories/${repositoryName}/artifacts`, method: 'GET',
params
},
options);
}
/**
* 获取指定 Artifact 的详细信息
* @summary 获取 Artifact 详情
*/
export const getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference = (
{ registryId, repositoryName, reference }: GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferencePathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoArtifactResponse>(
{url: `/registries/${registryId}/repositories/${repositoryName}/artifacts/${reference}`, method: 'GET'
},
options);
}
/**
* 获取 Helm Chart 的 values.schema.json (仅支持 Chart 类型)
* @summary 获取 Helm Chart Values Schema
*/
export const getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema = (
{ registryId, repositoryName, reference }: GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaPathParameters,
options?: SecondParameter<typeof customAxiosInstance>,) => {
return customAxiosInstance<GithubComOcdpClusterServiceInternalAdapterInputHttpDtoValuesSchemaResponse>(
{url: `/registries/${registryId}/repositories/${repositoryName}/artifacts/${reference}/values-schema`, method: 'GET'
},
options);
}
export type PostAuthLoginResult = NonNullable<Awaited<ReturnType<typeof postAuthLogin>>>
export type PostAuthRefreshResult = NonNullable<Awaited<ReturnType<typeof postAuthRefresh>>>
export type PostAuthRegisterResult = NonNullable<Awaited<ReturnType<typeof postAuthRegister>>>
export type GetClustersResult = NonNullable<Awaited<ReturnType<typeof getClusters>>>
export type PostClustersResult = NonNullable<Awaited<ReturnType<typeof postClusters>>>
export type DeleteClustersClusterIdResult = NonNullable<Awaited<ReturnType<typeof deleteClustersClusterId>>>
export type GetClustersClusterIdResult = NonNullable<Awaited<ReturnType<typeof getClustersClusterId>>>
export type PutClustersClusterIdResult = NonNullable<Awaited<ReturnType<typeof putClustersClusterId>>>
export type GetClustersClusterIdHealthResult = NonNullable<Awaited<ReturnType<typeof getClustersClusterIdHealth>>>
export type GetClustersClusterIdInstancesResult = NonNullable<Awaited<ReturnType<typeof getClustersClusterIdInstances>>>
export type PostClustersClusterIdInstancesResult = NonNullable<Awaited<ReturnType<typeof postClustersClusterIdInstances>>>
export type DeleteClustersClusterIdInstancesInstanceIdResult = NonNullable<Awaited<ReturnType<typeof deleteClustersClusterIdInstancesInstanceId>>>
export type GetClustersClusterIdInstancesInstanceIdResult = NonNullable<Awaited<ReturnType<typeof getClustersClusterIdInstancesInstanceId>>>
export type PutClustersClusterIdInstancesInstanceIdResult = NonNullable<Awaited<ReturnType<typeof putClustersClusterIdInstancesInstanceId>>>
export type GetClustersClusterIdInstancesInstanceIdEntriesResult = NonNullable<Awaited<ReturnType<typeof getClustersClusterIdInstancesInstanceIdEntries>>>
export type GetMonitoringClustersResult = NonNullable<Awaited<ReturnType<typeof getMonitoringClusters>>>
export type GetMonitoringClustersClusterIdResult = NonNullable<Awaited<ReturnType<typeof getMonitoringClustersClusterId>>>
export type GetMonitoringClustersClusterIdNodesResult = NonNullable<Awaited<ReturnType<typeof getMonitoringClustersClusterIdNodes>>>
export type GetMonitoringSummaryResult = NonNullable<Awaited<ReturnType<typeof getMonitoringSummary>>>
export type GetRegistriesResult = NonNullable<Awaited<ReturnType<typeof getRegistries>>>
export type PostRegistriesResult = NonNullable<Awaited<ReturnType<typeof postRegistries>>>
export type DeleteRegistriesRegistryIdResult = NonNullable<Awaited<ReturnType<typeof deleteRegistriesRegistryId>>>
export type GetRegistriesRegistryIdResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryId>>>
export type PutRegistriesRegistryIdResult = NonNullable<Awaited<ReturnType<typeof putRegistriesRegistryId>>>
export type GetRegistriesRegistryIdHealthResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryIdHealth>>>
export type GetRegistriesRegistryIdRepositoriesResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryIdRepositories>>>
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts>>>
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference>>>
export type GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaResult = NonNullable<Awaited<ReturnType<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema>>>

View File

@ -1,197 +0,0 @@
/**
* API Client entry point
* Export configured API client, generated functions, and friendly aliases.
*/
type AxiosOptions<T extends (...args: any) => any> = Parameters<T>[2];
import {
deleteClustersClusterId,
deleteClustersClusterIdInstancesInstanceId,
deleteRegistriesRegistryId,
getClusters,
getClustersClusterId,
getClustersClusterIdHealth,
getClustersClusterIdInstances,
getClustersClusterIdInstancesInstanceId,
getClustersClusterIdInstancesInstanceIdEntries,
getMonitoringClusters,
getMonitoringClustersClusterId,
getMonitoringClustersClusterIdNodes,
getMonitoringSummary,
getRegistries,
getRegistriesRegistryId,
getRegistriesRegistryIdHealth,
getRegistriesRegistryIdRepositories,
getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts,
getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference,
getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema,
postAuthLogin,
postAuthRefresh,
postAuthRegister,
postClusters,
postClustersClusterIdInstances,
postRegistries,
putClustersClusterId,
putClustersClusterIdInstancesInstanceId,
putRegistriesRegistryId,
} from './generated-orval/api';
import type {
DeleteClustersClusterIdInstancesInstanceIdPathParameters,
DeleteClustersClusterIdPathParameters,
DeleteRegistriesRegistryIdPathParameters,
GetClustersClusterIdInstancesInstanceIdPathParameters,
GetClustersClusterIdInstancesPathParameters,
GetRegistriesRegistryIdHealthPathParameters,
GetRegistriesRegistryIdPathParameters,
GetRegistriesRegistryIdRepositoriesPathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferencePathParameters,
GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaPathParameters,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoArtifactResponse as GeneratedArtifactResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoAuthResponse as GeneratedAuthResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterMetricsResponse as GeneratedClusterMetricsResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoClusterResponse as GeneratedClusterResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateClusterRequest as GeneratedCreateClusterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateInstanceRequest as GeneratedCreateInstanceRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoCreateRegistryRequest as GeneratedCreateRegistryRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceEntryResponse as GeneratedInstanceEntry,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponse as GeneratedInstanceResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoLoginRequest as GeneratedLoginRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoMonitoringSummaryResponse as GeneratedMonitoringSummary,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoNodeMetricsResponse as GeneratedNodeMetricsResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRefreshTokenRequest as GeneratedRefreshTokenRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegisterRequest as GeneratedRegisterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryHealthResponse as GeneratedRegistryHealthResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRegistryResponse as GeneratedRegistryResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoRepositoryListResponse as GeneratedRepositoryListResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoTagResponse as GeneratedTagResponse,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateClusterRequest as GeneratedUpdateClusterRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateInstanceRequest as GeneratedUpdateInstanceRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUpdateRegistryRequest as GeneratedUpdateRegistryRequest,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoUserResponse as GeneratedUserResponse,
PutClustersClusterIdInstancesInstanceIdPathParameters,
PutClustersClusterIdPathParameters,
PutRegistriesRegistryIdPathParameters,
} from './generated-orval/api.schemas';
import {
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus as GeneratedInstanceStatusEnum,
} from './generated-orval/api.schemas';
export { AXIOS_INSTANCE, customAxiosInstance, setAuthToken } from './axios-mutator';
export { keysToCamel, keysToSnake, snakeToCamel, camelToSnake } from '@/shared/utils/case-converter';
// Re-export raw generated APIs/types for advanced usages
export * from './generated-orval/api';
export type * from './generated-orval/api.schemas';
// ---------- Friendly type aliases ----------
export type AuthResponse = GeneratedAuthResponse;
export type RegisterBody = GeneratedRegisterRequest;
export type LoginBody = GeneratedLoginRequest;
export type RefreshTokenBody = GeneratedRefreshTokenRequest;
export type UserResponse = GeneratedUserResponse;
export type ClusterResponse = GeneratedClusterResponse;
export type CreateClusterRequest = GeneratedCreateClusterRequest;
export type UpdateClusterRequest = GeneratedUpdateClusterRequest;
export type RegistryResponse = GeneratedRegistryResponse;
export type CreateRegistryRequest = GeneratedCreateRegistryRequest;
export type UpdateRegistryRequest = GeneratedUpdateRegistryRequest;
export type RegistryHealthResponse = GeneratedRegistryHealthResponse;
export type InstanceResponse = GeneratedInstanceResponse;
export type CreateInstanceRequest = GeneratedCreateInstanceRequest;
export type UpdateInstanceRequest = GeneratedUpdateInstanceRequest;
export type InstanceEntry = GeneratedInstanceEntry;
export const INSTANCE_STATUS = GeneratedInstanceStatusEnum;
export type InstanceStatus = NonNullable<InstanceResponse['status']>;
export const INSTANCE_LAST_OPERATION = GeneratedInstanceLastOperationEnum;
export type InstanceLastOperation = NonNullable<InstanceResponse['lastOperation']>;
export type ArtifactResponse = GeneratedArtifactResponse;
export type ArtifactListItem = GeneratedTagResponse;
export type ListRepositories200Item =
| {
name?: string;
artifact_count?: number;
artifactCount?: number;
}
| string;
export type RepositoryListResponse = GeneratedRepositoryListResponse;
export type ListArtifactsFilter = 'all' | 'chart' | 'image' | 'other';
export type ClusterMonitoring = GeneratedClusterMetricsResponse;
export type ClusterMonitoringStatus = ClusterMonitoring['status'];
export type MonitoringSummary = GeneratedMonitoringSummary;
export type NodeMetricsResponse = GeneratedNodeMetricsResponse;
// ---------- Friendly function aliases ----------
export const login = postAuthLogin;
export const register = postAuthRegister;
export const refreshAuth = postAuthRefresh;
export const listClusters = getClusters;
export const createCluster = postClusters;
export const getCluster = getClustersClusterId;
export const updateCluster = putClustersClusterId;
export const deleteCluster = deleteClustersClusterId;
export const getClusterHealth = getClustersClusterIdHealth;
export const listInstances = getClustersClusterIdInstances;
export const createInstance = postClustersClusterIdInstances;
export const getInstance = getClustersClusterIdInstancesInstanceId;
export const updateInstance = putClustersClusterIdInstancesInstanceId;
export const deleteInstance = deleteClustersClusterIdInstancesInstanceId;
export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries;
export const listRegistries = getRegistries;
export const createRegistry = postRegistries;
export const getRegistry = getRegistriesRegistryId;
export const updateRegistry = putRegistriesRegistryId;
export const deleteRegistry = deleteRegistriesRegistryId;
export const checkRegistryHealth = getRegistriesRegistryIdHealth;
export const listRepositories = getRegistriesRegistryIdRepositories;
type ListArtifactsRequestOptions = AxiosOptions<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts>;
export const listArtifacts = (
params: GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters,
options?: { filter?: ListArtifactsFilter },
axiosOptions?: ListArtifactsRequestOptions,
) => {
const query =
options?.filter && options.filter !== 'all'
? { media_type: options.filter }
: undefined;
return getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts(params, query, axiosOptions);
};
export const getArtifact = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference;
export const getValuesSchema = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema;
export const listClusterMonitoring = getMonitoringClusters;
export const getClusterMonitoring = getMonitoringClustersClusterId;
export const getClusterNodeMetrics = getMonitoringClustersClusterIdNodes;
export const getMonitoringSummaryData = getMonitoringSummary;
// Re-export parameter types with friendly names for caller convenience
export type DeleteClusterPathParameters = DeleteClustersClusterIdPathParameters;
export type UpdateClusterPathParameters = PutClustersClusterIdPathParameters;
export type ClusterInstancesPathParameters = GetClustersClusterIdInstancesPathParameters;
export type InstancePathParameters = GetClustersClusterIdInstancesInstanceIdPathParameters;
export type UpdateInstancePathParameters = PutClustersClusterIdInstancesInstanceIdPathParameters;
export type DeleteInstancePathParameters = DeleteClustersClusterIdInstancesInstanceIdPathParameters;
export type RegistryPathParameters = GetRegistriesRegistryIdPathParameters;
export type UpdateRegistryPathParameters = PutRegistriesRegistryIdPathParameters;
export type DeleteRegistryPathParameters = DeleteRegistriesRegistryIdPathParameters;
export type RegistryHealthPathParameters = GetRegistriesRegistryIdHealthPathParameters;
export type ListRepositoriesPathParameters = GetRegistriesRegistryIdRepositoriesPathParameters;
export type ListArtifactsPathParameters = GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsPathParameters;
export type ListArtifactsParams = { filter?: ListArtifactsFilter };
export type GetArtifactPathParameters = GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferencePathParameters;
export type GetValuesSchemaPathParameters = GetRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchemaPathParameters;

View File

@ -1,38 +0,0 @@
/**
* Main Application Component
* 主应用组件
*/
import { useLocation, useNavigate } from "react-router-dom";
import { useMemo } from "react";
import { AppRoutes } from "./routes/AppRoutes";
import { useAuth } from "./providers";
import { getNavItems } from "./constants/navigation";
/**
* Application root component
* Manages routing and global state
*/
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated, login, logout } = useAuth();
// Generate navigation items based on current location
const navItems = useMemo(
() => getNavItems(location.pathname, navigate),
[location.pathname, navigate]
);
return (
<AppRoutes
isAuthenticated={isAuthenticated}
userName="User"
navItems={navItems}
onLogin={login}
onLogout={logout}
/>
);
}

View File

@ -0,0 +1,334 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { adminApi, workspaceApi } from '@/lib/api';
import type { UserDTO, WorkspaceDTO } from '@/lib/types';
import { Users, Plus, Trash2, Edit, Shield, ShieldOff } from 'lucide-react';
export default function UsersManagementPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<UserDTO[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<UserDTO | null>(null);
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
role: 'user',
workspace_id: '',
});
const fetchData = async () => {
try {
const [usersRes, workspacesRes] = await Promise.all([
adminApi.listUsers(),
workspaceApi.list(),
]);
setUsers(usersRes.data.users || []);
setWorkspaces(workspacesRes.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingUser) {
await adminApi.updateUser(editingUser.id, {
email: formData.email || undefined,
});
} else {
await adminApi.createUser({
username: formData.username,
password: formData.password,
email: formData.email || undefined,
role: formData.role,
workspace_id: formData.workspace_id || undefined,
});
}
setShowForm(false);
setEditingUser(null);
setFormData({ username: '', password: '', email: '', role: 'user', workspace_id: '' });
fetchData();
} catch (error) {
console.error('Failed to save user:', error);
alert('Failed to save user');
}
};
const handleEdit = (user: UserDTO) => {
setEditingUser(user);
setFormData({
username: user.username,
password: '',
email: user.email || '',
role: user.role,
workspace_id: user.workspace_id || '',
});
setShowForm(true);
};
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await adminApi.deleteUser(userId);
fetchData();
} catch (error) {
console.error('Failed to delete user:', error);
alert('Failed to delete user');
}
};
const handleToggleActive = async (user: UserDTO) => {
try {
await adminApi.setUserActive(user.id, !user.is_active);
fetchData();
} catch (error) {
console.error('Failed to toggle user status:', error);
alert('Failed to toggle user status');
}
};
const handleResetPassword = async (userId: string) => {
const newPassword = prompt('Enter new password:');
if (!newPassword || newPassword.length < 6) {
alert('Password must be at least 6 characters');
return;
}
try {
await adminApi.resetPassword(userId, newPassword);
alert('Password reset successfully');
} catch (error) {
console.error('Failed to reset password:', error);
alert('Failed to reset password');
}
};
if (currentUser?.role !== 'admin') {
return (
<div className="flex items-center justify-center h-64">
<p className="text-[var(--muted-foreground)]">Access denied. Admin only.</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">User Management</h1>
<p className="text-[var(--muted-foreground)]">Manage users and permissions</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingUser(null);
setFormData({ username: '', password: '', email: '', role: 'user', workspace_id: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingUser ? 'Edit User' : 'Add User'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Username</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="input"
required
disabled={!!editingUser}
/>
</div>
{!editingUser && (
<div>
<label className="label">Password</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input"
required={!editingUser}
minLength={6}
/>
</div>
)}
<div>
<label className="label">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="input"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="label">Workspace</label>
<select
value={formData.workspace_id}
onChange={(e) => setFormData({ ...formData, workspace_id: e.target.value })}
className="input"
>
<option value="">No workspace (Admin only)</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.name}
</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingUser(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
{editingUser ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Users Table */}
{users.length === 0 ? (
<div className="card text-center py-12">
<Users className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No users created</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Create your first user
</button>
</div>
) : (
<div className="card overflow-hidden p-0">
<table className="table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Workspace</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[var(--secondary)] flex items-center justify-center">
<span className="text-sm font-medium text-[var(--foreground)]">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<span className="font-medium text-[var(--foreground)]">{user.username}</span>
</div>
</td>
<td className="text-[var(--muted-foreground)]">{user.email || '-'}</td>
<td>
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-success'}`}>
{user.role}
</span>
</td>
<td className="text-[var(--muted-foreground)]">
{user.workspace_name || user.workspace_id || '-'}
</td>
<td>
<span className={`badge ${user.is_active ? 'badge-success' : 'badge-error'}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div className="flex items-center gap-1">
<button
onClick={() => handleToggleActive(user)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={user.is_active ? 'Deactivate' : 'Activate'}
>
{user.is_active ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
</button>
<button
onClick={() => handleResetPassword(user.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title="Reset Password"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(user)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,401 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { workspaceApi } from '@/lib/api';
import type { WorkspaceDTO, QuotaDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types';
import { FolderKanban, Plus, Trash2, Edit, Settings } from 'lucide-react';
export default function WorkspacesPage() {
const { user } = useAuth();
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [showQuotaForm, setShowQuotaForm] = useState(false);
const [editingWorkspace, setEditingWorkspace] = useState<WorkspaceDTO | null>(null);
const [selectedWorkspace, setSelectedWorkspace] = useState<WorkspaceDTO | null>(null);
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
name: '',
description: '',
});
const [quotaFormData, setQuotaFormData] = useState<SetQuotasRequest>({
cpu: { hard_limit: 10, soft_limit: 8 },
gpu: { hard_limit: 2, soft_limit: 1 },
gpu_memory: { hard_limit: 16, soft_limit: 8 },
});
const fetchWorkspaces = async () => {
try {
const response = await workspaceApi.list();
setWorkspaces(response.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch workspaces:', error);
} finally {
setIsLoading(false);
}
};
const fetchQuotas = async (workspaceId: string) => {
try {
const response = await workspaceApi.getQuotas(workspaceId);
setQuotas((prev) => ({ ...prev, [workspaceId]: response.data.quotas || [] }));
} catch (error) {
console.error('Failed to fetch quotas:', error);
}
};
useEffect(() => {
fetchWorkspaces();
}, []);
useEffect(() => {
workspaces.forEach((ws) => {
if (!quotas[ws.id]) {
fetchQuotas(ws.id);
}
});
}, [workspaces]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingWorkspace) {
await workspaceApi.update(editingWorkspace.id, formData);
} else {
await workspaceApi.create(formData);
}
setShowForm(false);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
fetchWorkspaces();
} catch (error) {
console.error('Failed to save workspace:', error);
alert('Failed to save workspace');
}
};
const handleEdit = (workspace: WorkspaceDTO) => {
setEditingWorkspace(workspace);
setFormData({ name: workspace.name, description: workspace.description || '' });
setShowForm(true);
};
const handleDelete = async (workspaceId: string) => {
if (!confirm('Are you sure you want to delete this workspace?')) return;
try {
await workspaceApi.delete(workspaceId);
fetchWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
alert('Failed to delete workspace');
}
};
const handleSaveQuotas = async () => {
if (!selectedWorkspace) return;
try {
await workspaceApi.setQuotas(selectedWorkspace.id, quotaFormData);
setShowQuotaForm(false);
fetchQuotas(selectedWorkspace.id);
} catch (error) {
console.error('Failed to save quotas:', error);
alert('Failed to save quotas');
}
};
if (user?.role !== 'admin') {
return (
<div className="flex items-center justify-center h-64">
<p className="text-[var(--muted-foreground)]">Access denied. Admin only.</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Workspaces</h1>
<p className="text-[var(--muted-foreground)]">Manage workspaces and quotas</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Workspace
</button>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingWorkspace(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
{editingWorkspace ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Quota Form Modal */}
{showQuotaForm && selectedWorkspace && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
Set Quotas for {selectedWorkspace.name}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">CPU (cores)</label>
<input
type="number"
value={quotaFormData.cpu?.hard_limit ?? 10}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
cpu: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.cpu?.soft_limit ?? 8 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">CPU Warning</label>
<input
type="number"
value={quotaFormData.cpu?.soft_limit ?? 8}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
cpu: { hard_limit: quotaFormData.cpu?.hard_limit ?? 10, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">GPU (cards)</label>
<input
type="number"
value={quotaFormData.gpu?.hard_limit ?? 2}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.gpu?.soft_limit ?? 1 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">GPU Warning</label>
<input
type="number"
value={quotaFormData.gpu?.soft_limit ?? 1}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu: { hard_limit: quotaFormData.gpu?.hard_limit ?? 2, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">GPU Memory (GB)</label>
<input
type="number"
value={quotaFormData.gpu_memory?.hard_limit ?? 16}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu_memory: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.gpu_memory?.soft_limit ?? 8 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">GPU Mem Warning</label>
<input
type="number"
value={quotaFormData.gpu_memory?.soft_limit ?? 8}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu_memory: { hard_limit: quotaFormData.gpu_memory?.hard_limit ?? 16, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowQuotaForm(false);
setSelectedWorkspace(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="button"
onClick={handleSaveQuotas}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
Save Quotas
</button>
</div>
</div>
</div>
</div>
)}
{/* Workspaces List */}
{workspaces.length === 0 ? (
<div className="card text-center py-12">
<FolderKanban className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No workspaces created</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Create your first workspace
</button>
</div>
) : (
<div className="grid gap-4">
{workspaces.map((workspace) => (
<div key={workspace.id} className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<FolderKanban className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<h3 className="font-semibold text-[var(--foreground)]">{workspace.name}</h3>
{workspace.description && (
<p className="text-sm text-[var(--muted-foreground)]">{workspace.description}</p>
)}
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Created: {new Date(workspace.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedWorkspace(workspace);
setShowQuotaForm(true);
}}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title="Set Quotas"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(workspace)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(workspace.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quotas Display */}
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">
<p className="text-sm font-medium text-[var(--foreground)] mb-2">Quotas</p>
<div className="grid grid-cols-3 gap-4">
{quotas[workspace.id].map((quota) => (
<div key={quota.id} className="bg-[var(--secondary)] rounded-lg p-3">
<p className="text-xs text-[var(--muted-foreground)] uppercase">{quota.resource_type}</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{quota.used} / {quota.hard_limit === 0 ? '∞' : quota.hard_limit}
</p>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,335 @@
'use client';
import { useEffect, useState } from 'react';
import { chartReferenceApi, registryApi } from '@/lib/api';
import type { ChartReferenceDTO, CreateChartReferenceRequest, UpdateChartReferenceRequest, RegistryDTO } from '@/lib/types';
import { Package, Plus, Trash2, Edit, ToggleLeft, ToggleRight, Search, Database } from 'lucide-react';
export default function ChartReferencesPage() {
const [chartRefs, setChartRefs] = useState<ChartReferenceDTO[]>([]);
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingChartRef, setEditingChartRef] = useState<ChartReferenceDTO | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState<CreateChartReferenceRequest>({
registry_id: '',
repository: '',
chart_name: '',
description: '',
is_enabled: true,
});
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
} catch (error) {
console.error('Failed to fetch chart references:', error);
} finally {
setIsLoading(false);
}
};
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
setRegistries(response.data.registries || []);
} catch (error) {
console.error('Failed to fetch registries:', error);
}
};
useEffect(() => {
fetchChartRefs();
fetchRegistries();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingChartRef) {
await chartReferenceApi.update(editingChartRef.id, formData as UpdateChartReferenceRequest);
} else {
await chartReferenceApi.create(formData);
}
setShowForm(false);
setEditingChartRef(null);
setFormData({ registry_id: '', repository: '', chart_name: '', description: '', is_enabled: true });
fetchChartRefs();
} catch (error) {
console.error('Failed to save chart reference:', error);
alert('Failed to save chart reference');
}
};
const handleEdit = (chartRef: ChartReferenceDTO) => {
setEditingChartRef(chartRef);
setFormData({
registry_id: chartRef.registry_id,
repository: chartRef.repository,
chart_name: chartRef.chart_name,
description: chartRef.description || '',
is_enabled: chartRef.is_enabled,
});
setShowForm(true);
};
const handleDelete = async (chartRefId: string) => {
if (!confirm('Are you sure you want to delete this chart reference?')) return;
try {
await chartReferenceApi.delete(chartRefId);
fetchChartRefs();
} catch (error) {
console.error('Failed to delete chart reference:', error);
alert('Failed to delete chart reference');
}
};
const handleToggleEnabled = async (chartRef: ChartReferenceDTO) => {
try {
await chartReferenceApi.update(chartRef.id, { is_enabled: !chartRef.is_enabled });
fetchChartRefs();
} catch (error) {
console.error('Failed to toggle chart reference:', error);
}
};
const getRegistryName = (registryId: string) => {
const registry = registries.find(r => r.id === registryId);
return registry?.name || registryId;
};
const filteredChartRefs = chartRefs.filter(cr =>
cr.chart_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
cr.repository.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Chart References</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Manage Helm chart references from OCI registries
</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingChartRef(null);
setFormData({ registry_id: '', repository: '', chart_name: '', description: '', is_enabled: true });
}}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" />
Add Chart Reference
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search chart references..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)]">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-4">
{editingChartRef ? 'Edit Chart Reference' : 'Add Chart Reference'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Registry
</label>
<select
value={formData.registry_id}
onChange={(e) => setFormData({ ...formData, registry_id: e.target.value })}
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">Select a registry</option>
{registries.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Repository (OCI path)
</label>
<input
type="text"
value={formData.repository}
onChange={(e) => setFormData({ ...formData, repository: e.target.value })}
placeholder="e.g., library/nginx"
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Chart Name
</label>
<input
type="text"
value={formData.chart_name}
onChange={(e) => setFormData({ ...formData, chart_name: e.target.value })}
placeholder="e.g., nginx"
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description..."
rows={2}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_enabled"
checked={formData.is_enabled}
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_enabled" className="text-sm text-[var(--foreground)]">
Enabled
</label>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingChartRef(null);
}}
className="flex-1 px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{editingChartRef ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Table */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--secondary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Chart
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Repository
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Registry
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{filteredChartRefs.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--muted-foreground)]">
No chart references found
</td>
</tr>
) : (
filteredChartRefs.map((cr) => (
<tr key={cr.id} className="hover:bg-[var(--secondary)] transition-colors">
<td className="px-4 py-3">
<button
onClick={() => handleToggleEnabled(cr)}
className="flex items-center gap-1"
>
{cr.is_enabled ? (
<ToggleRight className="w-5 h-5 text-green-500" />
) : (
<ToggleLeft className="w-5 h-5 text-gray-400" />
)}
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">{cr.chart_name}</span>
</div>
</td>
<td className="px-4 py-3 text-[var(--foreground)]">{cr.repository}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[var(--muted-foreground)]">
<Database className="w-3 h-3" />
{getRegistryName(cr.registry_id)}
</div>
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)] text-sm">
{cr.description || '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(cr)}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<Edit className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleDelete(cr.id)}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,523 @@
'use client';
import { useEffect, useState } from 'react';
import { registryApi, instanceApi, clusterApi } from '@/lib/api';
import { Package, Database, ChevronRight, Search, Rocket, X, Loader2 } from 'lucide-react';
interface RegistryDTO {
id: string;
name: string;
url: string;
description?: string;
username?: string;
insecure: boolean;
isShared: boolean;
createdAt: string;
updatedAt: string;
}
interface CreateInstanceRequest {
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
values_yaml?: string;
registry_id?: string;
}
interface ClusterDTO {
id: string;
name: string;
host: string;
description?: string;
}
export default function ChartsPage() {
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [selectedRegistry, setSelectedRegistry] = useState<RegistryDTO | null>(null);
const [repositories, setRepositories] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [isLoadingArtifacts, setIsLoadingArtifacts] = useState(false);
const [showDeployModal, setShowDeployModal] = useState(false);
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployForm, setDeployForm] = useState({
name: '',
namespace: 'default',
clusterId: '',
description: '',
valuesYaml: '',
});
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
const data = response.data;
setRegistries(Array.isArray(data) ? data : (data?.registries || []));
} catch (error) {
console.error('Failed to fetch registries:', error);
} finally {
setIsLoading(false);
}
};
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
const clusterList = Array.isArray(data) ? data : (data?.clusters || []);
setClusters(clusterList);
if (clusterList.length > 0 && !deployForm.clusterId) {
setDeployForm(prev => ({ ...prev, clusterId: clusterList[0].id }));
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
}
};
const fetchRepositories = async (registryId: string) => {
setIsLoadingRepos(true);
setRepositories([]);
setSelectedRepo(null);
try {
const response = await registryApi.listRepositories(registryId);
setRepositories(response.data?.repositories || []);
} catch (error) {
console.error('Failed to fetch repositories:', error);
} finally {
setIsLoadingRepos(false);
}
};
const fetchArtifacts = async (registryId: string, repositoryName: string) => {
setIsLoadingArtifacts(true);
setArtifacts([]);
try {
const response = await registryApi.listArtifacts(registryId, repositoryName);
// Filter to only show chart type artifacts
const allArtifacts = Array.isArray(response.data) ? response.data : [];
const chartArtifacts = allArtifacts.filter((a: Artifact) => a.type === 'chart');
setArtifacts(chartArtifacts);
} catch (error) {
console.error('Failed to fetch artifacts:', error);
} finally {
setIsLoadingArtifacts(false);
}
};
useEffect(() => {
fetchRegistries();
fetchClusters();
}, []);
useEffect(() => {
if (selectedRegistry) {
fetchRepositories(selectedRegistry.id);
}
}, [selectedRegistry]);
useEffect(() => {
if (selectedRegistry && selectedRepo) {
fetchArtifacts(selectedRegistry.id, selectedRepo);
}
}, [selectedRepo]);
const filteredRepos = repositories.filter(repo =>
repo.toLowerCase().includes(searchQuery.toLowerCase())
);
// Only show chart repositories (those starting with "charts/")
const chartRepos = filteredRepos.filter(repo => repo.startsWith('charts/') || repo.includes('/charts/'));
const handleDeploy = async () => {
if (!selectedArtifact || !deployForm.clusterId || !deployForm.name) {
alert('Please fill in all required fields');
return;
}
setIsDeploying(true);
setDeployError(null);
try {
const request: CreateInstanceRequest = {
name: deployForm.name,
namespace: deployForm.namespace || 'default',
repository: selectedRepo!,
chart: selectedRepo!.split('/').pop() || selectedRepo!,
version: selectedArtifact.tag,
registry_id: selectedRegistry?.id,
description: deployForm.description,
values_yaml: deployForm.valuesYaml || undefined,
};
await instanceApi.create(deployForm.clusterId, request);
alert('Deployment created successfully!');
setShowDeployModal(false);
setSelectedArtifact(null);
setDeployForm({
name: '',
namespace: 'default',
clusterId: clusters[0]?.id || '',
description: '',
valuesYaml: '',
});
} catch (error: unknown) {
console.error('Failed to deploy:', error);
let errorMessage = 'Failed to create deployment';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.error || errorMessage;
}
setDeployError(errorMessage);
} finally {
setIsDeploying(false);
}
};
const openDeployModal = (artifact: Artifact) => {
setSelectedArtifact(artifact);
setDeployForm(prev => ({
...prev,
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
}));
setShowDeployModal(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Helm Charts</h1>
<p className="text-[var(--muted-foreground)]">Browse and deploy Helm charts from OCI registries</p>
</div>
{/* Registry Selection */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-[var(--foreground)]">Registry:</label>
<div className="flex gap-2 flex-wrap">
{registries.map((registry) => (
<button
key={registry.id}
onClick={() => {
setSelectedRegistry(registry);
setSelectedRepo(null);
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${
selectedRegistry?.id === registry.id
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
: 'border-[var(--border)] text-[var(--foreground)] hover:border-[var(--primary)]'
}`}
>
<Database className="w-4 h-4" />
{registry.name}
</button>
))}
</div>
</div>
{/* Search */}
{selectedRegistry && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search charts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
)}
{/* Breadcrumb */}
{selectedRegistry && (
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => {
setSelectedRegistry(null);
setSelectedRepo(null);
}}
className="text-[var(--primary)] hover:underline"
>
Registries
</button>
<ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
<span className="text-[var(--foreground)]">{selectedRegistry.name}</span>
{selectedRepo && (
<>
<ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
<span className="text-[var(--foreground)]">{selectedRepo}</span>
</>
)}
</div>
)}
{/* Content */}
{!selectedRegistry ? (
<div className="card text-center py-12">
<Package className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">Select a registry to browse Helm charts</p>
</div>
) : isLoadingRepos ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : !selectedRepo ? (
/* Chart Repositories List */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{chartRepos.length === 0 ? (
<div className="col-span-full card text-center py-12">
<Package className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">
{searchQuery ? 'No charts found matching your search' : 'No Helm charts found in this registry'}
</p>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="mt-4 text-[var(--primary)] hover:underline"
>
Clear search
</button>
)}
</div>
) : (
chartRepos.map((repo) => (
<button
key={repo}
onClick={() => setSelectedRepo(repo)}
className="card text-left hover:border-[var(--primary)] transition-all p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-[var(--primary)]" />
<div>
<p className="font-semibold text-[var(--foreground)]">
{repo.split('/').pop()}
</p>
<p className="text-xs text-[var(--muted-foreground)]">{repo}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-[var(--muted-foreground)]" />
</div>
</button>
))
)}
</div>
) : isLoadingArtifacts ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : (
/* Artifact/Version List */
<div className="space-y-4">
<button
onClick={() => setSelectedRepo(null)}
className="flex items-center gap-2 text-sm text-[var(--primary)] hover:underline"
>
<ChevronRight className="w-4 h-4 rotate-180" />
Back to charts
</button>
<h2 className="text-lg font-semibold text-[var(--foreground)]">
Versions of {selectedRepo.split('/').pop()}
</h2>
{artifacts.length === 0 ? (
<div className="card text-center py-12">
<p className="text-[var(--muted-foreground)]">No versions found</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{artifacts.map((artifact, index) => (
<div key={index} className="card">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-mono text-lg font-bold text-[var(--foreground)]">
{artifact.tag}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{artifact.mediaType}
</p>
</div>
{index === 0 && (
<span className="badge badge-success">Latest</span>
)}
</div>
<p className="text-xs text-[var(--muted-foreground)] mb-4">
Size: {formatSize(artifact.size)}
</p>
<button
onClick={() => openDeployModal(artifact)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Rocket className="w-4 h-4" />
Deploy
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Deploy Modal */}
{showDeployModal && selectedArtifact && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
<button
onClick={() => {
setShowDeployModal(false);
setDeployError(null);
}}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<X className="w-5 h-5 text-[var(--muted-foreground)]" />
</button>
</div>
<div className="mb-4 p-3 bg-[var(--secondary)] rounded-lg">
<p className="text-sm text-[var(--muted-foreground)]">Deploying</p>
<p className="font-semibold text-[var(--foreground)]">
{selectedRepo}:{selectedArtifact.tag}
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Release Name *
</label>
<input
type="text"
value={deployForm.name}
onChange={(e) => setDeployForm({ ...deployForm, name: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
placeholder="my-release"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Namespace
</label>
<input
type="text"
value={deployForm.namespace}
onChange={(e) => setDeployForm({ ...deployForm, namespace: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
placeholder="default"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Cluster *
</label>
<select
value={deployForm.clusterId}
onChange={(e) => setDeployForm({ ...deployForm, clusterId: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
required
>
<option value="">Select a cluster</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<textarea
value={deployForm.description}
onChange={(e) => setDeployForm({ ...deployForm, description: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
rows={2}
placeholder="Optional description..."
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Custom Values (values.yaml)
</label>
<textarea
value={deployForm.valuesYaml}
onChange={(e) => setDeployForm({ ...deployForm, valuesYaml: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
rows={4}
placeholder="# Optional: Override chart values&#10;replicaCount: 2&#10;image:&#11; tag: latest"
/>
</div>
{deployError && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
{deployError}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowDeployModal(false);
setDeployError(null);
}}
className="flex-1 px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDeploy}
disabled={isDeploying || !deployForm.name || !deployForm.clusterId}
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 flex items-center justify-center gap-2"
>
{isDeploying && <Loader2 className="w-4 h-4 animate-spin" />}
{isDeploying ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
interface Artifact {
repositoryName: string;
tag: string;
type: string;
mediaType: string;
size: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

View File

@ -0,0 +1,348 @@
'use client';
import { useEffect, useState } from 'react';
import { clusterApi } from '@/lib/api';
import type { ClusterDTO, CreateClusterRequest } from '@/lib/types';
import { Server, Plus, Trash2, Edit, AlertCircle, CheckCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
export default function ClustersPage() {
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingCluster, setEditingCluster] = useState<ClusterDTO | null>(null);
const [showKubeconfig, setShowKubeconfig] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateClusterRequest>({
name: '',
host: '',
description: '',
isolationMode: 'namespace',
isShared: false,
caData: '',
certData: '',
keyData: '',
token: '',
});
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
// API returns array directly or { clusters: [] }
const data = response.data;
if (Array.isArray(data)) {
setClusters(data);
} else if (data && data.clusters) {
setClusters(data.clusters);
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchClusters();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Filter out empty kubeconfig fields
const dataToSend = {
...formData,
caData: formData.caData || undefined,
certData: formData.certData || undefined,
keyData: formData.keyData || undefined,
token: formData.token || undefined,
};
if (editingCluster) {
await clusterApi.update(editingCluster.id, dataToSend);
} else {
await clusterApi.create(dataToSend);
}
setShowForm(false);
setEditingCluster(null);
setFormData({ name: '', host: '', description: '', isolationMode: 'namespace', isShared: false, caData: '', certData: '', keyData: '', token: '' });
fetchClusters();
alert(editingCluster ? 'Cluster updated successfully!' : 'Cluster created and connected successfully!');
} catch (error: unknown) {
console.error('Failed to save cluster:', error);
let errorMessage = 'Failed to save cluster';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
if (axiosError.response?.data?.message) {
errorMessage = axiosError.response.data.message;
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
}
alert('Error: ' + errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (cluster: ClusterDTO) => {
setEditingCluster(cluster);
setFormData({
name: cluster.name,
host: cluster.host,
description: cluster.description || '',
isolationMode: cluster.isolationMode || 'namespace',
isShared: cluster.isShared,
caData: cluster.hasCaData ? '********' : '',
certData: cluster.hasCertData ? '********' : '',
keyData: cluster.hasKeyData ? '********' : '',
token: cluster.hasToken ? '********' : '',
});
// Show kubeconfig section if any cert is configured
setShowKubeconfig(cluster.hasCaData || cluster.hasCertData || cluster.hasKeyData || cluster.hasToken);
setShowForm(true);
};
const handleDelete = async (clusterId: string) => {
if (!confirm('Are you sure you want to delete this cluster?')) return;
try {
await clusterApi.delete(clusterId);
fetchClusters();
} catch (error) {
console.error('Failed to delete cluster:', error);
alert('Failed to delete cluster');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Clusters</h1>
<p className="text-[var(--muted-foreground)]">Manage Kubernetes clusters</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingCluster(null);
setShowKubeconfig(false);
setFormData({ name: '', host: '', description: '', isolationMode: 'namespace', isShared: false, caData: '', certData: '', keyData: '', token: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Cluster
</button>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingCluster ? 'Edit Cluster' : 'Add Cluster'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Host</label>
<input
type="text"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
className="input"
placeholder="https://kubernetes.example.com:6443"
required
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
/>
</div>
<div>
<label className="label">Isolation Mode</label>
<select
value={formData.isolationMode}
onChange={(e) => setFormData({ ...formData, isolationMode: e.target.value })}
className="input"
>
<option value="namespace">Namespace</option>
<option value="cluster">Cluster</option>
</select>
</div>
{/* Kubeconfig Section */}
<div>
<button
type="button"
onClick={() => setShowKubeconfig(!showKubeconfig)}
className="flex items-center gap-2 text-sm text-[var(--primary)] hover:underline"
>
{showKubeconfig ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
Kubeconfig (Optional)
</button>
</div>
{showKubeconfig && (
<>
<div>
<label className="label">CA Data (base64)</label>
<textarea
value={formData.caData || ''}
onChange={(e) => setFormData({ ...formData, caData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Client Certificate (base64)</label>
<textarea
value={formData.certData || ''}
onChange={(e) => setFormData({ ...formData, certData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Client Key (base64)</label>
<textarea
value={formData.keyData || ''}
onChange={(e) => setFormData({ ...formData, keyData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Token</label>
<input
type="password"
value={formData.token || ''}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
className="input"
placeholder="Bearer token (optional)"
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.isShared || false}
onChange={(e) => setFormData({ ...formData, isShared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared Cluster (visible to all workspaces)
</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingCluster(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingCluster ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Clusters List */}
{clusters.length === 0 ? (
<div className="card text-center py-12">
<Server className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first cluster
</button>
</div>
) : (
<div className="grid gap-4">
{clusters.map((cluster) => (
<div key={cluster.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<Server className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{cluster.name}</h3>
{cluster.isShared && (
<span className="badge badge-info">Shared</span>
)}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
{cluster.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{cluster.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{cluster.hasCaData ? (
<span title="Certificate configured"><CheckCircle className="w-4 h-4 text-green-500" /></span>
) : (
<span title="No certificate"><AlertCircle className="w-4 h-4 text-yellow-500" /></span>
)}
<button
onClick={() => handleEdit(cluster)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(cluster.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,130 +0,0 @@
/**
* Navigation Configuration
* 导航配置 - 集中管理导航菜单项
*/
import { Home, Settings, Server, Database, Package, LineChart } from "lucide-react";
/**
* Navigation item type
*/
export interface NavItem {
key: string;
label: string;
icon: React.ReactNode;
active?: boolean;
onClick?: () => void;
children?: NavItem[];
}
/**
* Page info type for header display
*/
export interface PageInfo {
title: string;
icon: React.ReactNode;
}
/**
* Get navigation items
* @param currentPath - Current route path
* @param navigate - Navigation function
* @returns Navigation items array
*/
export const getNavItems = (
currentPath: string,
navigate: (path: string) => void
): NavItem[] => [
{
key: "home",
label: "Home",
icon: <Home className="w-4 h-4 text-secondary" />,
active: currentPath === "/home",
onClick: () => navigate("/home"),
},
// Configuration
{
key: "configuration",
label: "Configuration",
icon: <Settings className="w-4 h-4 text-brand-accent" />,
children: [
{
key: "configuration-clusters",
label: "Clusters",
icon: <Server className="w-4 h-4 text-accent-teal" />,
active: currentPath === "/configuration/clusters",
onClick: () => navigate("/configuration/clusters"),
},
{
key: "configuration-registries",
label: "Registries",
icon: <Database className="w-4 h-4 text-brand-light" />,
active: currentPath === "/configuration/registries",
onClick: () => navigate("/configuration/registries"),
},
],
},
// Monitoring - 监控资源状态
{
key: "monitoring",
label: "Monitoring",
icon: <LineChart className="w-4 h-4 text-accent-teal" />,
children: [
{
key: "monitoring-clusters",
label: "Clusters",
icon: <Server className="w-4 h-4 text-accent-teal" />,
active: currentPath === "/monitoring/clusters",
onClick: () => navigate("/monitoring/clusters"),
},
],
},
// Artifact - 浏览和部署制品
{
key: "artifact",
label: "Artifact",
icon: <Package className="w-4 h-4 text-brand-light" />,
children: [
{
key: "artifact-registries",
label: "Registries",
icon: <Database className="w-4 h-4 text-brand-light" />,
active: currentPath === "/artifact/registries",
onClick: () => navigate("/artifact/registries"),
},
{
key: "artifact-instances",
label: "Instances",
icon: <Package className="w-4 h-4 text-brand-accent" />,
active: currentPath === "/artifact/instances",
onClick: () => navigate("/artifact/instances"),
},
],
},
];
/**
* Get page header info based on current path
* @param pathname - Current route pathname
* @returns Page info object
*/
export const getPageInfo = (pathname: string): PageInfo => {
if (pathname === "/artifact/registries") {
return { title: "Artifact Browser", icon: <Package className="w-6 h-6 text-brand-light" /> };
}
if (pathname === "/artifact/instances") {
return { title: "Artifact - Instances", icon: <Package className="w-6 h-6 text-brand-accent" /> };
}
if (pathname === "/configuration/clusters") {
return { title: "Configuration - Clusters", icon: <Server className="w-6 h-6 text-accent-teal" /> };
}
if (pathname === "/configuration/registries") {
return { title: "Configuration - Registries", icon: <Database className="w-6 h-6 text-brand-light" /> };
}
if (pathname === "/monitoring/clusters") {
return { title: "Monitoring - Clusters", icon: <LineChart className="w-6 h-6 text-accent-teal" /> };
}
return { title: "OCDP Platform", icon: <Home className="w-6 h-6 text-secondary" /> };
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,142 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #ededed;
--card: #171717;
--card-foreground: #ededed;
--popover: #171717;
--popover-foreground: #ededed;
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #262626;
--secondary-foreground: #ededed;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #262626;
--accent-foreground: #ededed;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #262626;
--input: #262626;
--ring: #3b82f6;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Custom utility classes */
.card {
background-color: var(--card);
border-radius: 0.5rem;
border: 1px solid var(--border);
padding: 1.5rem;
}
.input {
background-color: var(--input);
border: 1px solid var(--border);
color: var(--foreground);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
width: 100%;
}
.input:focus {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
margin-bottom: 0.25rem;
}
/* Table styles */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
color: var(--muted-foreground);
font-size: 0.75rem;
text-transform: uppercase;
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.table tr:hover {
background-color: var(--secondary);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success {
background-color: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.badge-warning {
background-color: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.badge-error {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.badge-info {
background-color: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}

View File

@ -1,11 +0,0 @@
/**
* App Module - Unified Export
* 应用模块统一导出
*/
export { default as App } from "./App";
export * from "./providers";
export * from "./routes/RouteGuard";
export * from "./constants/navigation";

View File

@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ClientLayout } from "@/components/client-layout";
export const metadata: Metadata = {
title: "OCDP Platform",
description: "Open Cloud Development Platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="min-h-screen bg-[var(--background)]">
<AuthProvider>
<ClientLayout>{children}</ClientLayout>
</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,13 @@
'use client';
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
{children}
</div>
);
}

View File

@ -0,0 +1,124 @@
'use client';
import { useState, FormEvent, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Shield, Loader2, CheckCircle } from 'lucide-react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loginSuccess, setLoginSuccess] = useState(false);
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
// Redirect if already logged in
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/');
}
}, [authLoading, isAuthenticated, router]);
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ username, password });
setLoginSuccess(true);
// Small delay to show success state, then redirect
setTimeout(() => {
router.push('/');
}, 500);
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || 'Login failed');
} else {
setError('Login failed. Please check your credentials.');
}
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<div className="w-full max-w-md p-8">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--primary)] mb-4">
<Shield className="w-8 h-8 text-[var(--primary-foreground)]" />
</div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">OCDP Platform</h1>
<p className="text-[var(--muted-foreground)] mt-2">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{loginSuccess && (
<div className="p-3 rounded-md bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.3)] flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<p className="text-sm text-green-500">Login successful! Redirecting...</p>
</div>
)}
{error && (
<div className="p-3 rounded-md bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.3)]">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
<div>
<label htmlFor="username" className="label">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
placeholder="Enter your username"
required
/>
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,278 @@
'use client';
import { useEffect, useState } from 'react';
import { monitoringApi } from '@/lib/api';
import { Activity, Server, Cpu, HardDrive, CircleDot } from 'lucide-react';
interface ClusterMetrics {
clusterId: string;
clusterName: string;
status: string;
nodeCount: number;
podCount: number;
cpuUsage: number;
memoryUsage: number;
totalCpu?: string;
totalMemory?: string;
totalGpu?: number;
usedCpu?: string;
usedMemory?: string;
usedGpu?: number;
gpuUsage?: number;
uptime?: string;
nodes?: NodeMetric[];
}
interface NodeMetric {
nodeName: string;
status: string;
role: string;
age: string;
podCount: number;
cpuCapacity: string;
cpuAllocatable: string;
cpuUsage: string;
cpuPercent: number;
memoryCapacity: string;
memoryAllocatable: string;
memoryUsage: string;
memoryPercent: number;
gpuCapacity?: number;
gpuUsage?: number;
gpuPercent?: number;
gpuType?: string;
osImage?: string;
kernelVersion?: string;
containerRuntime?: string;
kubeletVersion?: string;
}
export default function MonitoringPage() {
const [clusterMonitoring, setClusterMonitoring] = useState<ClusterMetrics[]>([]);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchMonitoring = async () => {
try {
const response = await monitoringApi.listClusterMonitoring();
const data = Array.isArray(response.data) ? response.data : [];
setClusterMonitoring(data);
// Auto-select first cluster if available
if (data.length > 0 && !selectedCluster) {
setSelectedCluster(data[0].clusterId);
}
} catch (error) {
console.error('Failed to fetch monitoring data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchMonitoring();
}, []);
const selectedClusterData = clusterMonitoring.find(c => c.clusterId === selectedCluster);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Monitoring</h1>
<p className="text-[var(--muted-foreground)]">Monitor cluster and node health</p>
</div>
{/* Cluster Monitoring Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clusterMonitoring.length === 0 ? (
<div className="col-span-full card text-center py-12">
<Activity className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No clusters configured or accessible</p>
</div>
) : (
clusterMonitoring.map((cluster) => (
<div
key={cluster.clusterId}
className={`card cursor-pointer transition-all hover:border-[var(--primary)] ${
selectedCluster === cluster.clusterId ? 'border-[var(--primary)] ring-1 ring-[var(--primary)]' : ''
}`}
onClick={() => setSelectedCluster(cluster.clusterId)}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[var(--primary)]" />
<h3 className="font-semibold text-[var(--foreground)]">{cluster.clusterName}</h3>
</div>
<span className={`badge ${
cluster.status === 'healthy' ? 'badge-success' :
cluster.status === 'warning' ? 'badge-warning' : 'badge-error'
}`}>
{cluster.status}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-[var(--muted-foreground)]">CPU Usage</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.cpuUsage?.toFixed(1)}%
</p>
{cluster.totalCpu && (
<p className="text-xs text-[var(--muted-foreground)]">{cluster.usedCpu} / {cluster.totalCpu}</p>
)}
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Memory Usage</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.memoryUsage?.toFixed(1)}%
</p>
{cluster.totalMemory && (
<p className="text-xs text-[var(--muted-foreground)]">{cluster.usedMemory} / {cluster.totalMemory}</p>
)}
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Nodes</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{cluster.nodeCount}</p>
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Pods</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{cluster.podCount}</p>
</div>
{cluster.totalGpu !== undefined && cluster.totalGpu > 0 && (
<>
<div>
<p className="text-xs text-[var(--muted-foreground)]">GPU</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.gpuUsage?.toFixed(0) ?? 0}%
</p>
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">GPU Capacity</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.usedGpu ?? 0} / {cluster.totalGpu}
</p>
</div>
</>
)}
</div>
{cluster.uptime && (
<p className="text-xs text-[var(--muted-foreground)] mt-3">Uptime: {cluster.uptime}</p>
)}
</div>
))
)}
</div>
{/* Node Metrics Table */}
{selectedClusterData && selectedClusterData.nodes && (
<div className="card">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
Nodes in {selectedClusterData.clusterName}
</h2>
{selectedClusterData.nodes.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No node data available</p>
) : (
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>Node</th>
<th>Role</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
<th>GPU</th>
<th>Pods</th>
</tr>
</thead>
<tbody>
{selectedClusterData.nodes.map((node) => (
<tr key={node.nodeName}>
<td>
<div>
<p className="font-medium text-[var(--foreground)]">{node.nodeName}</p>
<p className="text-xs text-[var(--muted-foreground)]">{node.osImage}</p>
</div>
</td>
<td>
<span className={`badge ${node.role === 'control-plane' ? 'badge-info' : 'badge-secondary'}`}>
{node.role}
</span>
</td>
<td>
<span className={`flex items-center gap-1 ${
node.status === 'Ready' ? 'text-green-500' : 'text-red-500'
}`}>
<CircleDot className="w-3 h-3" />
{node.status}
</span>
</td>
<td>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-blue-500"
style={{ width: `${Math.min(node.cpuPercent, 100)}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)] whitespace-nowrap">
{node.cpuUsage} ({node.cpuPercent.toFixed(1)}%)
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">{node.cpuCapacity}</p>
</td>
<td>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-purple-500"
style={{ width: `${Math.min(node.memoryPercent, 100)}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)] whitespace-nowrap">
{node.memoryPercent.toFixed(1)}%
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">{node.memoryUsage}</p>
</td>
<td>
{node.gpuCapacity && node.gpuCapacity > 0 ? (
<div>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${node.gpuPercent ?? 0}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)]">
{node.gpuPercent ?? 0}%
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{node.gpuCapacity}x {node.gpuType || 'GPU'}
</p>
</div>
) : (
<span className="text-[var(--muted-foreground)]">-</span>
)}
</td>
<td className="text-[var(--foreground)]">{node.podCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

174
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { monitoringApi, clusterApi } from '@/lib/api';
import { Activity, Server, Container } from 'lucide-react';
interface DashboardStats {
totalClusters: number;
healthyClusters: number;
totalInstances: number;
runningInstances: number;
totalNodes: number;
}
export default function DashboardPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [clusters, setClusters] = useState<Array<{ id: string; name: string; host: string }>>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isLoading, isAuthenticated, router]);
useEffect(() => {
const fetchData = async () => {
try {
const [summaryRes, clustersRes] = await Promise.all([
monitoringApi.getSummary().catch(() => null),
clusterApi.list().catch(() => null),
]);
if (summaryRes?.data) {
setStats({
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
healthyClusters: summaryRes.data.healthyClusters ?? summaryRes.data.healthy_clusters ?? 0,
totalInstances: summaryRes.data.totalInstances ?? summaryRes.data.total_instances ?? 0,
runningInstances: summaryRes.data.runningInstances ?? summaryRes.data.running_instances ?? 0,
totalNodes: summaryRes.data.totalNodes ?? summaryRes.data.total_nodes ?? 0,
});
}
// Handle both {clusters: []} and array response
const clustersData = clustersRes?.data;
if (Array.isArray(clustersData)) {
setClusters(clustersData);
} else if (clustersData?.clusters) {
setClusters(clustersData.clusters);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setIsLoadingData(false);
}
};
if (!isLoading) {
fetchData();
}
}, [isLoading]);
if (isLoading || isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Dashboard</h1>
<p className="text-[var(--muted-foreground)]">
Welcome back, {user?.username}
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Total Clusters"
value={stats?.totalClusters ?? 0}
icon={Server}
color="blue"
/>
<StatCard
title="Healthy Clusters"
value={stats?.healthyClusters ?? 0}
icon={Activity}
color="green"
/>
<StatCard
title="Total Instances"
value={stats?.totalInstances ?? 0}
icon={Container}
color="purple"
/>
<StatCard
title="Running Instances"
value={stats?.runningInstances ?? 0}
icon={Activity}
color="green"
/>
</div>
{/* Clusters List */}
<div className="card">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">Clusters</h2>
{clusters.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
) : (
<div className="space-y-2">
{clusters.map((cluster) => (
<div
key={cluster.id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--secondary)]"
>
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[var(--primary)]" />
<div>
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
</div>
</div>
<div className="badge badge-success">Active</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function StatCard({
title,
value,
icon: Icon,
color,
}: {
title: string;
value: number;
icon: React.ElementType;
color: 'blue' | 'green' | 'purple' | 'orange';
}) {
const colorClasses = {
blue: 'text-blue-500 bg-blue-500/10',
green: 'text-green-500 bg-green-500/10',
purple: 'text-purple-500 bg-purple-500/10',
orange: 'text-orange-500 bg-orange-500/10',
};
return (
<div className="card">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-[var(--muted-foreground)]">{title}</p>
<p className="text-2xl font-bold text-[var(--foreground)]">{value}</p>
</div>
</div>
</div>
);
}

View File

@ -1,23 +0,0 @@
/**
* Auth Context
* Authentication context - separated for Fast Refresh compatibility
*/
import { createContext } from "react";
import type { AuthResponse } from "@/api";
export interface User {
username: string;
role?: string;
}
export interface AuthContextType {
token: string | null;
user: User | null;
isAuthenticated: boolean;
login: (response: AuthResponse) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);

View File

@ -1,103 +0,0 @@
/**
* Authentication Provider
* 认证提供者 - 管理用户认证状态和 token
*/
import { useState, useEffect } from "react";
import type { ReactNode } from "react";
import type { AuthResponse } from "@/api";
import { setAuthToken } from "@/api";
import { AuthContext, type User } from "./AuthContext";
interface AuthProviderProps {
children: ReactNode;
devMode?: boolean;
}
/**
* Auth Provider Component
* Manages authentication state and provides auth context
*/
export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) => {
const [token, setToken] = useState<string | null>(devMode ? "dev-token" : null);
const [user, setUser] = useState<User | null>(null);
// Initialize: read token and user from localStorage
useEffect(() => {
if (devMode) {
const devUser: User = {
username: "dev-user",
role: "admin",
};
localStorage.setItem("access_token", "dev-token");
localStorage.setItem("user", JSON.stringify(devUser));
setToken("dev-token");
setUser(devUser);
return;
}
const storedToken = localStorage.getItem("access_token");
const storedUser = localStorage.getItem("user");
if (storedToken) {
setToken(storedToken);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (e) {
console.error("Failed to parse stored user:", e);
}
}
}, [devMode]);
// Sync token changes to axios headers
useEffect(() => {
setAuthToken(token);
}, [token]);
// Handle login (JWT format)
const login = (response: AuthResponse) => {
// JWT 格式: { accessToken, refreshToken, username, ... }
const accessToken = response.accessToken || "";
const refreshToken = response.refreshToken || "";
localStorage.setItem("access_token", accessToken);
localStorage.setItem("refresh_token", refreshToken);
const user: User = {
username: response.username || "",
role: "user", // 后端暂未返回 role默认为 user
};
localStorage.setItem("user", JSON.stringify(user));
setToken(accessToken);
setUser(user);
};
// Handle logout
const logout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
setToken(null);
setUser(null);
setAuthToken(null);
};
return (
<AuthContext.Provider
value={{
token,
user,
isAuthenticated: !!token,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
// useAuth hook is now exported from ./useAuth.ts for Fast Refresh compatibility

View File

@ -1,9 +0,0 @@
/**
* Providers - Unified Export
* 提供者统一导出
*/
export { AuthProvider } from "./AuthProvider";
export { useAuth } from "./useAuth";

View File

@ -1,19 +0,0 @@
/**
* useAuth Hook
* Auth context hook - separated for Fast Refresh compatibility
*/
import { useContext } from "react";
import { AuthContext } from "./AuthContext";
/**
* Hook to use auth context
*/
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@ -0,0 +1,304 @@
'use client';
import { useEffect, useState } from 'react';
import { registryApi } from '@/lib/api';
import type { RegistryDTO, CreateRegistryRequest } from '@/lib/types';
import { Database, Plus, Trash2, Edit, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
export default function RegistriesPage() {
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingRegistry, setEditingRegistry] = useState<RegistryDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateRegistryRequest>({
name: '',
url: '',
username: '',
password: '',
description: '',
insecure: false,
isShared: false,
});
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
// API returns array directly or { registries: [] }
const data = response.data;
if (Array.isArray(data)) {
setRegistries(data);
} else if (data && data.registries) {
setRegistries(data.registries);
} else {
setRegistries([]);
}
} catch (error) {
console.error('Failed to fetch registries:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRegistries();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const dataToSend = {
...formData,
username: formData.username || undefined,
password: formData.password || undefined,
};
if (editingRegistry) {
await registryApi.update(editingRegistry.id, dataToSend);
} else {
await registryApi.create(dataToSend);
}
setShowForm(false);
setEditingRegistry(null);
setFormData({ name: '', url: '', username: '', password: '', description: '', insecure: false, isShared: false });
fetchRegistries();
alert(editingRegistry ? 'Registry updated successfully!' : 'Registry connected successfully!');
} catch (error: unknown) {
console.error('Failed to save registry:', error);
let errorMessage = 'Failed to save registry';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
if (axiosError.response?.data?.message) {
errorMessage = axiosError.response.data.message;
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
}
alert('Error: ' + errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (registry: RegistryDTO) => {
setEditingRegistry(registry);
setFormData({
name: registry.name,
url: registry.url,
username: registry.username || '',
description: registry.description || '',
insecure: registry.insecure,
isShared: registry.isShared,
});
setShowForm(true);
};
const handleDelete = async (registryId: string) => {
if (!confirm('Are you sure you want to delete this registry?')) return;
try {
await registryApi.delete(registryId);
fetchRegistries();
} catch (error) {
console.error('Failed to delete registry:', error);
alert('Failed to delete registry');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Registries</h1>
<p className="text-[var(--muted-foreground)]">Manage OCI/Harbor registries</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingRegistry(null);
setFormData({ name: '', url: '', username: '', password: '', description: '', insecure: false, isShared: false });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Registry
</button>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingRegistry ? 'Edit Registry' : 'Add Registry'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">URL</label>
<input
type="text"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
className="input"
placeholder="https://harbor.example.com"
required
/>
</div>
<div>
<label className="label">Username (optional)</label>
<input
type="text"
value={formData.username || ''}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Password (optional)</label>
<input
type="password"
value={formData.password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="insecure"
checked={formData.insecure || false}
onChange={(e) => setFormData({ ...formData, insecure: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="insecure" className="text-sm text-[var(--foreground)]">
Insecure (HTTP)
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.isShared || false}
onChange={(e) => setFormData({ ...formData, isShared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared
</label>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingRegistry(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingRegistry ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Registries List */}
{registries.length === 0 ? (
<div className="card text-center py-12">
<Database className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No registries configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first registry
</button>
</div>
) : (
<div className="grid gap-4">
{registries.map((registry) => (
<div key={registry.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<Database className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{registry.name}</h3>
{registry.isShared && (
<span className="badge badge-info">Shared</span>
)}
{registry.insecure && (
<span className="badge badge-warning">Insecure</span>
)}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{registry.url}</p>
{registry.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{registry.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{registry.username ? (
<span title="Credentials configured"><CheckCircle className="w-4 h-4 text-green-500" /></span>
) : (
<span title="No credentials"><AlertCircle className="w-4 h-4 text-yellow-500" /></span>
)}
<button
onClick={() => handleEdit(registry)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(registry.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,182 +0,0 @@
/**
* Application Routes Configuration
* 应用路由配置
*/
import { Routes, Route, Navigate } from "react-router-dom";
import { ProtectedRoute } from "./RouteGuard";
import AppShell from "@/shared/components/layout/AppShell";
import { getPageInfo, type NavItem } from "../constants/navigation";
import { useLocation } from "react-router-dom";
import type { AuthResponse } from "@/api";
// Feature pages
import AuthPage from "@/features/auth/pages/AuthPage";
import HomePage from "@/features/home/pages/HomePage";
import ClusterConfigPage from "@/features/configuration/clusters/pages/ClusterConfigPage";
import RegistryConfigPage from "@/features/configuration/registries/pages/RegistryConfigPage";
import ArtifactBrowserPage from "@/features/artifact/registries/pages/ArtifactBrowserPage";
import InstancesManagementPage from "@/features/artifact/instances/pages/InstancesManagementPage";
import MonitoringClustersPage from "@/features/monitoring/clusters/pages/MonitoringClustersPage";
import { ApiTest } from "@/components/ApiTest";
interface AppRoutesProps {
isAuthenticated: boolean;
userName?: string;
navItems: NavItem[];
onLogin: (tokens: AuthResponse) => void;
onLogout: () => void;
}
/**
* Main application routes
*/
export const AppRoutes = ({
isAuthenticated,
userName = "User",
navItems,
onLogin,
onLogout,
}: AppRoutesProps) => {
const location = useLocation();
const pageInfo = getPageInfo(location.pathname);
return (
<Routes>
{/* Public route - Authentication page */}
<Route
path="/"
element={
isAuthenticated ? (
<Navigate to="/home" replace />
) : (
<AuthPage onLogin={onLogin} />
)
}
/>
{/* Protected routes - wrapped in AppShell */}
<Route
path="/home"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<HomePage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/configuration/clusters"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<ClusterConfigPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/configuration/registries"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<RegistryConfigPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/artifact/registries"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<ArtifactBrowserPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/artifact/instances"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<InstancesManagementPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/monitoring/clusters"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<MonitoringClustersPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* API Test page - Public for testing */}
<Route path="/api-test" element={<ApiTest />} />
{/* Legacy path compatibility - redirects */}
<Route path="/config" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/cluster" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/clusters" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/app" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/config/registry" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/config/registries" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/artifact/registry" element={<Navigate to="/artifact/registries" replace />} />
<Route path="/artifact/instance" element={<Navigate to="/artifact/instances" replace />} />
<Route path="/monitor" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/cluster" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/cluster/monitor" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/registry" element={<Navigate to="/artifact/registries" replace />} />
<Route path="/register" element={<Navigate to="/" replace />} />
</Routes>
);
};

View File

@ -1,39 +0,0 @@
/**
* Route Guard Component
* 路由守卫组件 - 处理认证和授权
*/
import { Navigate } from "react-router-dom";
import type { ReactNode } from "react";
interface RouteGuardProps {
isAuthenticated: boolean;
redirectTo?: string;
children: ReactNode;
}
/**
* Protected route wrapper
* Redirects to auth page if not authenticated
*/
export const ProtectedRoute = ({
isAuthenticated,
redirectTo = "/",
children
}: RouteGuardProps) => {
return isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
};
/**
* Public route wrapper
* Redirects to home if already authenticated
*/
export const PublicRoute = ({
isAuthenticated,
redirectTo = "/home",
children
}: RouteGuardProps) => {
return !isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
};

View File

@ -0,0 +1,383 @@
'use client';
import { useEffect, useState } from 'react';
import { storageApi } from '@/lib/api';
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest } from '@/lib/types';
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2 } from 'lucide-react';
export default function StoragePage() {
const [storages, setStorages] = useState<StorageDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateStorageRequest>({
name: '',
type: 'nfs',
description: '',
is_default: false,
is_shared: false,
});
const fetchStorages = async () => {
try {
const response = await storageApi.list();
setStorages(response.data || []);
} catch (error) {
console.error('Failed to fetch storages:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStorages();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
if (editingStorage) {
await storageApi.update(editingStorage.id, formData as UpdateStorageRequest);
} else {
await storageApi.create(formData);
}
setShowForm(false);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
fetchStorages();
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
} catch (error) {
console.error('Failed to save storage:', error);
alert('Failed to save storage backend: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (storage: StorageDTO) => {
setEditingStorage(storage);
setFormData({
name: storage.name,
type: storage.type,
description: storage.description || '',
is_default: storage.is_default,
is_shared: storage.is_shared,
nfs: storage.config.nfs,
pv: storage.config.pv,
hostPath: storage.config.hostPath,
});
setShowForm(true);
};
const handleDelete = async (storageId: string) => {
if (!confirm('Are you sure you want to delete this storage backend?')) return;
try {
await storageApi.delete(storageId);
fetchStorages();
} catch (error) {
console.error('Failed to delete storage:', error);
alert('Failed to delete storage backend');
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'nfs':
return <Server className="w-6 h-6 text-blue-500" />;
case 'pv':
return <HardDrive className="w-6 h-6 text-purple-500" />;
case 'hostPath':
return <Folder className="w-6 h-6 text-green-500" />;
default:
return <HardDrive className="w-6 h-6 text-gray-500" />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'nfs':
return 'NFS';
case 'pv':
return 'Persistent Volume';
case 'hostPath':
return 'Host Path';
default:
return type;
}
};
const renderConfig = (storage: StorageDTO) => {
if (storage.config.nfs) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">Server:</span> {storage.config.nfs.server}
<br />
<span className="font-medium">Path:</span> {storage.config.nfs.path}
</div>
);
}
if (storage.config.pv) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">StorageClass:</span> {storage.config.pv.storageClassName}
<br />
<span className="font-medium">Capacity:</span> {storage.config.pv.capacity}
</div>
);
}
if (storage.config.hostPath) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">Path:</span> {storage.config.hostPath.path}
</div>
);
}
return null;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Storage Backends</h1>
<p className="text-[var(--muted-foreground)]">Manage NFS, PV, and HostPath storage</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Storage
</button>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingStorage ? 'Edit Storage Backend' : 'Add Storage Backend'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="input"
>
<option value="nfs">NFS</option>
<option value="pv">Persistent Volume</option>
<option value="hostPath">Host Path</option>
</select>
</div>
{/* NFS Config */}
{formData.type === 'nfs' && (
<>
<div>
<label className="label">NFS Server</label>
<input
type="text"
value={formData.nfs?.server || ''}
onChange={(e) => setFormData({ ...formData, nfs: { ...formData.nfs, server: e.target.value, path: formData.nfs?.path || '' } })}
className="input"
placeholder="nfs.example.com"
/>
</div>
<div>
<label className="label">NFS Path</label>
<input
type="text"
value={formData.nfs?.path || ''}
onChange={(e) => setFormData({ ...formData, nfs: { ...formData.nfs, server: formData.nfs?.server || '', path: e.target.value } })}
className="input"
placeholder="/exports/data"
/>
</div>
</>
)}
{/* PV Config */}
{formData.type === 'pv' && (
<>
<div>
<label className="label">Storage Class Name</label>
<input
type="text"
value={formData.pv?.storageClassName || ''}
onChange={(e) => setFormData({ ...formData, pv: { ...formData.pv, storageClassName: e.target.value, capacity: formData.pv?.capacity || '10Gi', accessModes: formData.pv?.accessModes || ['ReadWriteOnce'] } })}
className="input"
placeholder="standard"
/>
</div>
<div>
<label className="label">Capacity</label>
<input
type="text"
value={formData.pv?.capacity || ''}
onChange={(e) => setFormData({ ...formData, pv: { ...formData.pv, storageClassName: formData.pv?.storageClassName || '', capacity: e.target.value, accessModes: formData.pv?.accessModes || ['ReadWriteOnce'] } })}
className="input"
placeholder="10Gi"
/>
</div>
</>
)}
{/* HostPath Config */}
{formData.type === 'hostPath' && (
<div>
<label className="label">Host Path</label>
<input
type="text"
value={formData.hostPath?.path || ''}
onChange={(e) => setFormData({ ...formData, hostPath: { path: e.target.value } })}
className="input"
placeholder="/mnt/data"
/>
</div>
)}
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isDefault"
checked={formData.is_default || false}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isDefault" className="text-sm text-[var(--foreground)]">
Default
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.is_shared || false}
onChange={(e) => setFormData({ ...formData, is_shared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared
</label>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingStorage(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingStorage ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Storages List */}
{storages.length === 0 ? (
<div className="card text-center py-12">
<HardDrive className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No storage backends configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first storage backend
</button>
</div>
) : (
<div className="grid gap-4">
{storages.map((storage) => (
<div key={storage.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
{getTypeIcon(storage.type)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{storage.name}</h3>
<span className="badge">{getTypeLabel(storage.type)}</span>
{storage.is_default && (
<span className="badge badge-info">Default</span>
)}
{storage.is_shared && (
<span className="badge badge-success">Shared</span>
)}
</div>
{renderConfig(storage)}
{storage.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{storage.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(storage)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(storage.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,435 @@
'use client';
import { useEffect, useState } from 'react';
import { valuesTemplateApi, chartReferenceApi } from '@/lib/api';
import type { ValuesTemplateDTO, CreateValuesTemplateRequest, UpdateValuesTemplateRequest, ChartReferenceDTO } from '@/lib/types';
import { FileText, Plus, Trash2, Edit, History, RotateCcw, Search, Package, Tag } from 'lucide-react';
export default function TemplatesPage() {
const [templates, setTemplates] = useState<ValuesTemplateDTO[]>([]);
const [chartRefs, setChartRefs] = useState<ChartReferenceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ValuesTemplateDTO | null>(null);
const [selectedChartRef, setSelectedChartRef] = useState<string>('');
const [historyTemplates, setHistoryTemplates] = useState<ValuesTemplateDTO[]>([]);
const [historyName, setHistoryName] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState<CreateValuesTemplateRequest>({
chart_reference_id: '',
name: '',
description: '',
values_yaml: '',
is_default: false,
});
const fetchTemplates = async () => {
try {
const response = await valuesTemplateApi.list();
setTemplates(response.data || []);
} catch (error) {
console.error('Failed to fetch templates:', error);
} finally {
setIsLoading(false);
}
};
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
} catch (error) {
console.error('Failed to fetch chart references:', error);
}
};
useEffect(() => {
fetchTemplates();
fetchChartRefs();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTemplate) {
await valuesTemplateApi.update(editingTemplate.id, formData as UpdateValuesTemplateRequest);
} else {
await valuesTemplateApi.create(formData);
}
setShowForm(false);
setEditingTemplate(null);
setFormData({ chart_reference_id: '', name: '', description: '', values_yaml: '', is_default: false });
fetchTemplates();
} catch (error) {
console.error('Failed to save template:', error);
alert('Failed to save values template');
}
};
const handleEdit = (template: ValuesTemplateDTO) => {
setEditingTemplate(template);
setFormData({
chart_reference_id: template.chart_reference_id,
name: template.name,
description: template.description || '',
values_yaml: template.values_yaml,
is_default: template.is_default,
});
setShowForm(true);
};
const handleDelete = async (templateId: string) => {
if (!confirm('Are you sure you want to delete this template? All versions will be deleted.')) return;
try {
await valuesTemplateApi.delete(templateId);
fetchTemplates();
} catch (error) {
console.error('Failed to delete template:', error);
alert('Failed to delete template');
}
};
const handleViewHistory = async (chartRefId: string, name: string) => {
try {
const response = await valuesTemplateApi.getHistory(chartRefId, name);
setHistoryTemplates(response.data || []);
setSelectedChartRef(chartRefId);
setHistoryName(name);
setShowHistory(true);
} catch (error) {
console.error('Failed to fetch history:', error);
alert('Failed to fetch template history');
}
};
const handleRollback = async (templateId: string) => {
if (!confirm('Are you sure you want to rollback to this version?')) return;
try {
await valuesTemplateApi.rollback(selectedChartRef, templateId);
setShowHistory(false);
fetchTemplates();
alert('Rollback successful');
} catch (error) {
console.error('Failed to rollback:', error);
alert('Failed to rollback template');
}
};
const getChartRefName = (chartRefId: string) => {
const chartRef = chartRefs.find(cr => cr.id === chartRefId);
return chartRef?.chart_name || chartRefId;
};
// Group templates by chart_reference_id and name, show only latest version
const latestTemplates = templates.reduce((acc, template) => {
const key = `${template.chart_reference_id}-${template.name}`;
if (!acc[key] || template.version > acc[key].version) {
acc[key] = template;
}
return acc;
}, {} as Record<string, ValuesTemplateDTO>);
const filteredTemplates = Object.values(latestTemplates).filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
getChartRefName(t.chart_reference_id).toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Values Templates</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Manage Helm values templates with version control
</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingTemplate(null);
setFormData({ chart_reference_id: '', name: '', description: '', values_yaml: '', is_default: false });
}}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" />
Add Template
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-2xl border border-[var(--border)] max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-4">
{editingTemplate ? `Edit Template (v${editingTemplate.version}) - Creating new version` : 'Add Values Template'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Chart Reference
</label>
<select
value={formData.chart_reference_id}
onChange={(e) => setFormData({ ...formData, chart_reference_id: e.target.value })}
required
disabled={!!editingTemplate}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] disabled:opacity-50"
>
<option value="">Select a chart reference</option>
{chartRefs.map((cr) => (
<option key={cr.id} value={cr.id}>{cr.chart_name} ({cr.repository})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Template Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., production, development, default"
required
disabled={!!editingTemplate}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description..."
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Values YAML
</label>
<textarea
value={formData.values_yaml}
onChange={(e) => setFormData({ ...formData, values_yaml: e.target.value })}
placeholder="replicaCount: 1&#10;image: &#10; repository: nginx&#10; tag: latest"
required
rows={12}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_default"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_default" className="text-sm text-[var(--foreground)]">
Set as default template
</label>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingTemplate(null);
}}
className="flex-1 px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{editingTemplate ? 'Create New Version' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* History Modal */}
{showHistory && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-2xl border border-[var(--border)] max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-2">
Version History
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{historyName} - {getChartRefName(selectedChartRef)}
</p>
<div className="space-y-3">
{historyTemplates.map((template) => (
<div
key={template.id}
className="p-4 border border-[var(--border)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">Version {template.version}</span>
{template.is_default && (
<span className="px-2 py-0.5 text-xs bg-[var(--primary)] text-[var(--primary-foreground)] rounded">
Default
</span>
)}
</div>
<button
onClick={() => handleRollback(template.id)}
className="flex items-center gap-1 px-3 py-1 text-sm border border-[var(--border)] text-[var(--foreground)] rounded hover:bg-[var(--secondary)] transition-colors"
>
<RotateCcw className="w-3 h-3" />
Rollback
</button>
</div>
<div className="text-xs text-[var(--muted-foreground)] mb-2">
Created: {new Date(template.createdAt).toLocaleString()}
</div>
<pre className="text-xs text-[var(--foreground)] bg-[var(--background)] p-2 rounded overflow-x-auto max-h-32">
{template.values_yaml}
</pre>
</div>
))}
{historyTemplates.length === 0 && (
<p className="text-center text-[var(--muted-foreground)] py-4">
No version history found
</p>
)}
</div>
<div className="mt-4">
<button
onClick={() => setShowHistory(false)}
className="w-full px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Table */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--secondary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Template
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Chart
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Version
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Default
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{filteredTemplates.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--muted-foreground)]">
No templates found
</td>
</tr>
) : (
filteredTemplates.map((template) => (
<tr key={`${template.chart_reference_id}-${template.name}`} className="hover:bg-[var(--secondary)] transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">{template.name}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[var(--muted-foreground)]">
<Package className="w-3 h-3" />
{getChartRefName(template.chart_reference_id)}
</div>
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs bg-[var(--secondary)] text-[var(--foreground)] rounded">
v{template.version}
</span>
</td>
<td className="px-4 py-3">
{template.is_default ? (
<span className="px-2 py-1 text-xs bg-green-500/20 text-green-500 rounded">Yes</span>
) : (
<span className="text-[var(--muted-foreground)]">-</span>
)}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)] text-sm">
{template.description || '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleViewHistory(template.chart_reference_id, template.name)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Version History"
>
<History className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleEdit(template)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Edit (Create New Version)"
>
<Edit className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleDelete(template.id)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Delete"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,272 +0,0 @@
import { useState } from 'react';
import {
register,
login,
listClusters,
createCluster,
setAuthToken,
type RegisterBody,
type LoginBody,
type CreateClusterRequest,
} from '@/api';
export function ApiTest() {
const [result, setResult] = useState<string>('');
const [loading, setLoading] = useState(false);
const [token, setToken] = useState<string>('');
const addLog = (message: string) => {
setResult((prev) => prev + '\n' + message);
console.log(message);
};
const clearLog = () => {
setResult('');
};
const handleRegister = async () => {
setLoading(true);
clearLog();
try {
addLog('📝 测试注册...');
const request: RegisterBody = {
username: 'testuser',
password: 'password123',
};
addLog(`发送请求: ${JSON.stringify(request, null, 2)}`);
const response = await register(request);
addLog(`✅ 注册成功!`);
addLog(`响应: ${JSON.stringify(response, null, 2)}`);
} catch (error: any) {
addLog(`❌ 注册失败: ${error.message}`);
if (error.response?.data) {
addLog(`错误详情: ${JSON.stringify(error.response.data, null, 2)}`);
}
} finally {
setLoading(false);
}
};
const handleLogin = async () => {
setLoading(true);
clearLog();
try {
addLog('🔐 测试登录...');
const request: LoginBody = {
username: 'testuser',
password: 'password123',
};
addLog(`发送请求: ${JSON.stringify(request, null, 2)}`);
const response = await login(request);
addLog(`✅ 登录成功!`);
addLog(`响应 (注意 camelCase): ${JSON.stringify(response, null, 2)}`);
// 验证响应字段是否为 camelCase
if (response.accessToken) {
addLog(`✅ 验证: accessToken 字段存在 (camelCase) ✓`);
setToken(response.accessToken);
setAuthToken(response.accessToken);
} else {
addLog(`❌ 错误: accessToken 字段不存在!`);
}
} catch (error: any) {
addLog(`❌ 登录失败: ${error.message}`);
if (error.response?.data) {
addLog(`错误详情: ${JSON.stringify(error.response.data, null, 2)}`);
}
} finally {
setLoading(false);
}
};
const handleListClusters = async () => {
setLoading(true);
clearLog();
try {
addLog('📋 测试获取集群列表...');
const clusters = await listClusters();
addLog(`✅ 获取成功! 共 ${clusters.length} 个集群`);
addLog(`响应 (注意 camelCase): ${JSON.stringify(clusters, null, 2)}`);
// 验证响应字段
if (clusters.length > 0) {
const firstCluster = clusters[0];
if ('createdAt' in firstCluster) {
addLog(`✅ 验证: createdAt 字段存在 (camelCase) ✓`);
}
if ('hasCaData' in firstCluster) {
addLog(`✅ 验证: hasCaData 字段存在 (camelCase) ✓`);
}
}
} catch (error: any) {
addLog(`❌ 获取失败: ${error.message}`);
if (error.response?.data) {
addLog(`错误详情: ${JSON.stringify(error.response.data, null, 2)}`);
}
} finally {
setLoading(false);
}
};
const handleCreateCluster = async () => {
setLoading(true);
clearLog();
try {
addLog('🚀 测试创建集群 (使用 camelCase)...');
const request: CreateClusterRequest = {
name: 'test-cluster',
host: 'https://k8s.test.example.com:6443',
description: '测试集群 - camelCase 验证',
caData: 'LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t', // ✅ camelCase
certData: 'LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t', // ✅ camelCase
keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt', // ✅ camelCase
};
addLog(`发送请求 (camelCase):`);
addLog(JSON.stringify(request, null, 2));
const response = await createCluster(request);
addLog(`✅ 创建成功!`);
addLog(`响应: ${JSON.stringify(response, null, 2)}`);
// 验证响应字段
if ('createdAt' in response) {
addLog(`✅ 验证: createdAt 字段存在 (camelCase) ✓`);
}
if ('caData' in response || 'hasCaData' in response) {
addLog(`✅ 验证: caData/hasCaData 字段存在 (camelCase) ✓`);
}
} catch (error: any) {
addLog(`❌ 创建失败: ${error.message}`);
if (error.response?.data) {
addLog(`错误详情: ${JSON.stringify(error.response.data, null, 2)}`);
}
} finally {
setLoading(false);
}
};
const handleFullTest = async () => {
setLoading(true);
clearLog();
addLog('🧪 开始完整测试流程...\n');
try {
// 1. 注册
addLog('步骤 1: 注册用户');
try {
await register({
username: `testuser_${Date.now()}`,
password: 'password123',
});
addLog('✅ 注册成功\n');
} catch (e: any) {
addLog('⚠️ 注册失败 (可能用户已存在)\n');
}
// 2. 登录
addLog('步骤 2: 登录');
const loginResponse = await login({
username: 'testuser',
password: 'password123',
});
addLog(`✅ 登录成功 - Token: ${loginResponse.accessToken?.substring(0, 20)}...`);
setAuthToken(loginResponse.accessToken || '');
addLog('');
// 3. 获取集群列表
addLog('步骤 3: 获取集群列表');
const clusters = await listClusters();
addLog(`✅ 获取成功 - 共 ${clusters.length} 个集群\n`);
// 4. 创建集群
addLog('步骤 4: 创建测试集群 (camelCase)');
const newCluster = await createCluster({
name: `test-cluster-${Date.now()}`,
host: 'https://k8s.test.example.com:6443',
description: 'Full test cluster',
caData: 'LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t',
certData: 'LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t',
keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt',
});
addLog(`✅ 创建成功 - ID: ${newCluster.id}\n`);
addLog('🎉 完整测试流程完成! 所有 API 调用成功camelCase 工作正常!');
} catch (error: any) {
addLog(`\n❌ 测试失败: ${error.message}`);
if (error.response?.data) {
addLog(`错误详情: ${JSON.stringify(error.response.data, null, 2)}`);
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: '20px', fontFamily: 'monospace' }}>
<h1>🧪 OCDP API - camelCase </h1>
<div style={{ marginBottom: '20px' }}>
<h3> Token:</h3>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="JWT Token"
style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
/>
<button onClick={() => setAuthToken(token)}> Token</button>
</div>
<div style={{ marginBottom: '20px' }}>
<h3>:</h3>
<button onClick={handleRegister} disabled={loading} style={{ marginRight: '10px' }}>
1.
</button>
<button onClick={handleLogin} disabled={loading} style={{ marginRight: '10px' }}>
2.
</button>
<button onClick={handleListClusters} disabled={loading} style={{ marginRight: '10px' }}>
3.
</button>
<button onClick={handleCreateCluster} disabled={loading} style={{ marginRight: '10px' }}>
4. (camelCase)
</button>
<button
onClick={handleFullTest}
disabled={loading}
style={{ marginRight: '10px', fontWeight: 'bold', backgroundColor: '#4CAF50', color: 'white' }}
>
🚀
</button>
<button onClick={clearLog} disabled={loading}>
</button>
</div>
<div
style={{
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
padding: '15px',
borderRadius: '5px',
minHeight: '400px',
maxHeight: '600px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
fontSize: '12px',
}}
>
{result || '点击按钮开始测试...'}
</div>
{loading && (
<div style={{ marginTop: '10px', color: '#2196F3' }}>
...
</div>
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
'use client';
import { usePathname } from "next/navigation";
import { Sidebar } from "@/components/sidebar";
export function ClientLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === '/login';
if (isLoginPage) {
return <>{children}</>;
}
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 ml-64 p-6">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,141 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import {
LayoutDashboard,
Users,
FolderKanban,
Server,
Database,
Activity,
LogOut,
Shield,
HardDrive,
Package,
FileText,
} from 'lucide-react';
import { logout } from '@/lib/api';
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Clusters', href: '/clusters', icon: Server },
{ name: 'Registries', href: '/registries', icon: Database },
{ name: 'Charts', href: '/charts', icon: Package },
{ name: 'Storage', href: '/storage', icon: HardDrive },
{ name: 'Chart References', href: '/chart-references', icon: FileText },
{ name: 'Values Templates', href: '/templates', icon: FileText },
{ name: 'Monitoring', href: '/monitoring', icon: Activity },
];
const adminNavigation = [
{ name: 'Workspaces', href: '/admin/workspaces', icon: FolderKanban },
{ name: 'User Management', href: '/admin/users', icon: Users },
];
export function Sidebar() {
const pathname = usePathname();
const { user, logout: authLogout } = useAuth();
const handleLogout = () => {
authLogout();
window.location.href = '/login';
};
const isAdmin = user?.role === 'admin';
return (
<div className="fixed left-0 top-0 h-screen w-64 bg-[var(--card)] border-r border-[var(--border)] flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--primary)] flex items-center justify-center">
<Shield className="w-6 h-6 text-[var(--primary-foreground)]" />
</div>
<div>
<h1 className="font-bold text-[var(--foreground)]">OCDP</h1>
<p className="text-xs text-[var(--muted-foreground)]">Platform</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--secondary)] hover:text-[var(--foreground)]'
}`}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
);
})}
{/* Admin Section */}
{isAdmin && (
<>
<div className="pt-4 pb-2">
<p className="px-3 text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wider">
Administration
</p>
</div>
{adminNavigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--secondary)] hover:text-[var(--foreground)]'
}`}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
);
})}
</>
)}
</nav>
{/* User Info & Logout */}
<div className="p-4 border-t border-[var(--border)]">
{user && (
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-[var(--secondary)] flex items-center justify-center">
<span className="text-sm font-medium text-[var(--foreground)]">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--foreground)] truncate">
{user.username}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{user.role === 'admin' ? 'Administrator' : 'User'}
</p>
</div>
</div>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm font-medium text-[var(--muted-foreground)] hover:bg-[var(--secondary)] hover:text-[var(--foreground)] transition-colors"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
</div>
);
}

View File

@ -1,45 +0,0 @@
/**
* API Configuration
* Unified management of all API-related configurations
* Frontend always connects to the backend service
*/
/**
* API service endpoint configuration
* Dev environment uses Vite proxy, production environment uses actual address
*/
const API_ENDPOINTS = {
// Local backend service (Go) - Cluster and Registry management
BACKEND: "/api",
};
/**
* Get API base path
* @param service - Service name (currently only BACKEND)
* @returns API base path
*/
export function getApiBasePath(service: keyof typeof API_ENDPOINTS = "BACKEND"): string {
return API_ENDPOINTS[service];
}
/**
* Default API base path (backend service)
*/
export const API_BASE_URL = getApiBasePath("BACKEND");
/**
* 获取应用名称
*/
export function getAppName(): string {
// @ts-ignore - 全局变量由 Vite 注入
return typeof __APP_NAME__ !== 'undefined' ? __APP_NAME__ : 'OCDP';
}
/**
* 打印配置信息
*/
export function logConfig(): void {
console.log('📋 API Configuration:');
console.log(' - API Base URL:', API_BASE_URL);
console.log(' - App Name:', getAppName());
}

View File

@ -1,81 +0,0 @@
/**
* Unified API Hook
* 统一的 API Hook - 提供统一的数据获取接口
*/
import { useState, useEffect } from "react";
/**
* 统一的数据获取 Hook
* @param fetchFn - 数据获取函数
* @param deps - 依赖数组
*/
export function useUnifiedQuery<T>(
fetchFn: () => Promise<T>,
deps: any[] = []
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
setLoading(true);
setError(null);
const result = await fetchFn();
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err as Error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, deps);
return { data, loading, error, refetch: () => fetchFn() };
}
/**
* 统一的变更 Hook
* @param mutateFn - 变更函数
*/
export function useUnifiedMutation<TData, TVariables>(
mutateFn: (variables: TVariables) => Promise<TData>
) {
const [data, setData] = useState<TData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const mutate = async (variables: TVariables) => {
try {
setLoading(true);
setError(null);
const result = await mutateFn(variables);
setData(result);
return result;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
return { mutate, data, loading, error };
}

View File

@ -1,20 +0,0 @@
/**
* Core Module - Unified Export
* 核心模块统一导出
*
* 包含应用的核心基础设施:
* - api/ API 客户端和接口
* - config/ 配置管理
* - types/ 类型定义
*/
// API exports are now in @/api
// export * from "./api";
// Export configuration
export * from "./config/api-config";
// Export common types
export * from "./types";

View File

@ -1,90 +0,0 @@
/**
* Core Type Definitions
* Re-exports from API generated types + additional UI types
*/
// ==================== Re-export API Models ====================
// Import from generated-orval (Orval-generated types)
export type {
ClusterResponse,
CreateClusterRequest,
UpdateClusterRequest,
} from '@/api';
export type {
RegistryResponse,
CreateRegistryRequest,
UpdateRegistryRequest,
} from '@/api';
export type {
InstanceResponse,
CreateInstanceRequest,
UpdateInstanceRequest,
} from '@/api';
export type {
ArtifactResponse,
ArtifactListItem,
} from '@/api';
export type {
ClusterMonitoring,
MonitoringSummary,
} from '@/api';
// ==================== Type Aliases (for convenience) ====================
// These maintain compatibility with existing code
import type {
ClusterResponse,
RegistryResponse,
InstanceResponse,
} from '@/api';
export type Cluster = ClusterResponse;
export type ClusterConfig = ClusterResponse;
export type Registry = RegistryResponse;
export type AppRegistry = RegistryResponse;
export type Instance = InstanceResponse;
export type AppInstance = InstanceResponse;
// ==================== UI-specific Types ====================
export interface HealthStatus {
clusterId: string;
status: string;
message: string;
checkedAt: string;
}
export interface RegistryHealthStatus {
healthy: boolean;
message?: string;
}
// ==================== Monitoring Extended Types ====================
// Extended types for monitoring features (includes API fields + UI fields)
import type { ClusterMonitoring, ClusterMonitoringStatus, NodeMetricsResponse } from '@/api';
export type NodeMetrics = NodeMetricsResponse;
export interface ClusterMetrics extends ClusterMonitoring {
/** Internal UI identifier (legacy) */
id?: string;
nodes?: NodeMetrics[];
status?: ClusterMonitoringStatus | 'warning' | 'error';
}
// ==================== Common Types ====================
export interface SuccessResponse {
message: string;
}
export interface ErrorResponse {
error: string;
message: string;
}

View File

@ -1,16 +0,0 @@
/**
* Artifact Module
* 制品模块 - 制品仓库浏览和实例管理
*/
// Registries (Browser) - VSCode-style unified browser
export { default as ArtifactBrowserPage } from './registries/pages/ArtifactBrowserPage';
export * from './registries/components/RepositoryItem';
export * from './registries/components/LaunchModal';
export * from './registries/components/TagCard';
// Instances
export { default as InstancesManagementPage } from './instances/pages/InstancesManagementPage';
export * from './instances/components/InstanceCard';
export * from './instances/components/ModifyModal';
export * from './instances/components/EntriesModal';

View File

@ -1,602 +0,0 @@
/**
* Entries Modal Component
* 显示实例的入口信息Services 和 Ingresses
*/
import React, { useState, useEffect } from "react";
import { X, Globe, Network, ExternalLink, Copy, CheckCircle, Info } from "lucide-react";
import { listInstanceEntries } from "@/api";
import type { InstanceEntry, InstanceResponse } from "@/api";
import { useToast } from "@/shared";
interface ServiceEntry {
name?: string;
namespace?: string;
type?: string;
cluster_ip?: string;
external_ips?: string[];
ports?: Array<{
name?: string;
protocol?: string;
port?: number | string;
target_port?: number | string;
node_port?: number;
}>;
loadBalancer?: {
ingress?: Array<{
ip?: string;
hostname?: string;
}>;
};
}
interface IngressEntry {
name?: string;
namespace?: string;
class_name?: string;
rules?: Array<{
host?: string;
paths?: Array<{
path?: string;
path_type?: string;
backend?: {
service?: {
name?: string;
port?: number | string;
};
};
}>;
}>;
tls?: Array<{
hosts?: string[];
secret_name?: string;
}>;
}
type EntrySource = 'kubernetes' | 'manifest' | 'notes' | 'none';
interface InstanceEntries {
services: ServiceEntry[];
ingresses: IngressEntry[];
source: EntrySource;
notes?: string;
}
const NESTED_ENTRY_KEYS = ["entries", "data", "result", "results", "payload", "services", "ingresses"];
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function collectEntryContainers(value: unknown, seen = new Set<unknown>()): Record<string, unknown>[] {
if (!isPlainObject(value) || seen.has(value)) {
return [];
}
seen.add(value);
const containers: Record<string, unknown>[] = [value];
for (const key of NESTED_ENTRY_KEYS) {
const nested = (value as Record<string, unknown>)[key];
if (isPlainObject(nested)) {
containers.push(...collectEntryContainers(nested, seen));
}
}
return containers;
}
function asEntryArray(value: unknown, seen = new Set<unknown>()): InstanceEntry[] | undefined {
if (Array.isArray(value)) {
return value as InstanceEntry[];
}
if (isPlainObject(value)) {
if (seen.has(value)) {
return undefined;
}
seen.add(value);
return (
asEntryArray(value.entries, seen) ||
asEntryArray(value.items, seen) ||
asEntryArray(value.data, seen)
);
}
return undefined;
}
const ENTRY_SOURCES: EntrySource[] = ["kubernetes", "manifest", "notes", "none"];
async function getInstanceEntries(clusterId: string, instanceId: string): Promise<InstanceEntries> {
const raw = (await listInstanceEntries({ clusterId, instanceId })) as unknown;
return buildInstanceEntries(raw);
}
function buildInstanceEntries(raw: unknown): InstanceEntries {
const baseState: InstanceEntries = {
services: [],
ingresses: [],
source: "none",
};
if (raw == null) {
return baseState;
}
let entriesPayload: InstanceEntry[] | undefined = Array.isArray(raw) ? (raw as InstanceEntry[]) : undefined;
let source: EntrySource = "none";
let notes: string | undefined;
let servicesFromResponse: InstanceEntry[] | undefined;
let ingressesFromResponse: InstanceEntry[] | undefined;
const containers = collectEntryContainers(raw);
for (const container of containers) {
entriesPayload ||= asEntryArray(container.entries);
entriesPayload ||= asEntryArray(container.items);
entriesPayload ||= asEntryArray(container.data);
servicesFromResponse ||= asEntryArray(container.services);
ingressesFromResponse ||= asEntryArray(container.ingresses);
if (!notes && typeof container.notes === "string") {
notes = container.notes;
}
if (typeof container.source === "string") {
const normalized = normalizeSource(container.source);
if (normalized) {
source = normalized;
}
}
}
const entries = entriesPayload ?? [];
const splitResult = entries.length ? splitEntries(entries) : { services: [], ingresses: [] };
const explicitServices = servicesFromResponse?.map(mapServiceEntry) ?? [];
const explicitIngresses = ingressesFromResponse?.map(mapIngressEntry) ?? [];
const services = explicitServices.length ? explicitServices : splitResult.services;
const ingresses = explicitIngresses.length ? explicitIngresses : splitResult.ingresses;
const resolvedSource: EntrySource =
services.length || ingresses.length
? source === "none"
? "kubernetes"
: source
: source;
return {
services,
ingresses,
source: resolvedSource,
notes,
};
}
function normalizeSource(source: string): EntrySource | null {
if (ENTRY_SOURCES.includes(source as EntrySource)) {
return source as EntrySource;
}
return null;
}
function splitEntries(entries: InstanceEntry[]): {
services: ServiceEntry[];
ingresses: IngressEntry[];
} {
if (!entries.length) {
return { services: [], ingresses: [] };
}
const services: ServiceEntry[] = [];
const ingresses: IngressEntry[] = [];
entries.forEach((entry) => {
const kind = entry.kind?.toLowerCase();
const looksLikeIngress =
kind === "ingress" ||
(!!entry.hosts && entry.hosts.length > 0) ||
(!!entry.tls && entry.tls.length > 0);
const looksLikeService =
kind === "service" ||
(!!entry.ports && entry.ports.length > 0) ||
!!entry.clusterIP ||
!!entry.type;
if (kind === "ingress" || (looksLikeIngress && !looksLikeService)) {
ingresses.push(mapIngressEntry(entry));
} else {
services.push(mapServiceEntry(entry));
}
});
return { services, ingresses };
}
function mapServiceEntry(entry: InstanceEntry): ServiceEntry {
return {
name: entry.name,
namespace: entry.namespace,
type: entry.type,
cluster_ip: entry.clusterIP,
external_ips: entry.externalIPs,
ports: entry.ports?.map((port) => ({
name: port.name,
protocol: port.protocol,
port: port.port ?? port.targetPort,
target_port: port.targetPort ?? port.port,
node_port: port.nodePort,
})),
loadBalancer: mapLoadBalancer(entry.loadBalancerIngress),
};
}
function mapIngressEntry(entry: InstanceEntry): IngressEntry {
return {
name: entry.name,
namespace: entry.namespace,
class_name: entry.type,
rules: entry.hosts?.map((host) => ({
host: host.host,
paths: host.paths?.map((path) => ({
path: path.path,
backend: {
service: {
name: path.serviceName,
port: normalizePort(path.servicePort),
},
},
})),
})),
tls: entry.tls?.map((tls) => ({
hosts: tls.hosts,
secret_name: tls.secretName,
})),
};
}
function mapLoadBalancer(values?: string[]) {
if (!values || !values.length) {
return undefined;
}
return {
ingress: values.map((value) => {
if (!value) {
return {};
}
const hasAlpha = /[a-zA-Z]/.test(value);
if (hasAlpha) {
return { hostname: value };
}
return { ip: value };
}),
};
}
function normalizePort(port?: string | number | null): number | string | undefined {
if (port == null) {
return undefined;
}
if (typeof port === "number") {
return port;
}
const parsed = Number(port);
if (!Number.isNaN(parsed)) {
return parsed;
}
return port;
}
interface EntriesModalProps {
instance: InstanceResponse;
onClose: () => void;
}
export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose }) => {
const { success } = useToast();
const [entries, setEntries] = useState<InstanceEntries | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedText, setCopiedText] = useState<string | null>(null);
useEffect(() => {
const loadEntries = async () => {
if (!instance.clusterId || !instance.id) {
setError("Instance identifier is missing");
setLoading(false);
return;
}
try {
setLoading(true);
const data = await getInstanceEntries(instance.clusterId, instance.id);
setEntries(data);
} catch (err: unknown) {
setError((err as Error).message || "Failed to load entries");
} finally {
setLoading(false);
}
};
loadEntries();
}, [instance.clusterId, instance.id]);
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedText(text);
success(`Copied ${label} to clipboard`);
setTimeout(() => setCopiedText(null), 2000);
};
const getSourceBadge = (source?: string) => {
const badges = {
kubernetes: { color: "bg-green-600/20 text-green-400 border-green-500/30", label: "Live from Kubernetes" },
manifest: { color: "bg-blue-600/20 text-blue-400 border-blue-500/30", label: "From Helm Manifest" },
notes: { color: "bg-yellow-600/20 text-yellow-400 border-yellow-500/30", label: "From Helm Notes" },
none: { color: "bg-gray-600/20 text-gray-400 border-gray-500/30", label: "No Data Available" },
};
const badge = badges[source as keyof typeof badges] || badges.none;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.color}`}>
<Info className="w-3 h-3" />
{badge.label}
</span>
);
};
const renderService = (service: ServiceEntry, index: number) => (
<div key={service.name || `service-${index}`} className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-semibold text-white">{service.name || `Service ${index + 1}`}</h4>
<p className="text-xs text-gray-400 mt-1">Type: {service.type || 'Unknown'}</p>
</div>
<span className="px-2 py-1 text-xs font-medium bg-blue-600/20 text-blue-400 border border-blue-500/30 rounded">
{service.type || 'Unknown'}
</span>
</div>
<div className="space-y-2">
{/* Cluster IP */}
{service.cluster_ip && (
<div className="flex items-center justify-between bg-gray-900/50 rounded p-2">
<span className="text-xs text-gray-400">Cluster IP:</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white">{service.cluster_ip}</span>
<button
onClick={() => copyToClipboard(service.cluster_ip!, "Cluster IP")}
className="p-1 hover:bg-gray-700 rounded transition"
>
{copiedText === service.cluster_ip ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
)}
</button>
</div>
</div>
)}
{/* Ports */}
{service.ports && service.ports.length > 0 && service.ports.map((port, idx) => (
<div key={idx} className="flex items-center justify-between bg-gray-900/50 rounded p-2">
<span className="text-xs text-gray-400">{port.name || `Port ${idx + 1}`}:</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-white">
{port.port} {port.target_port} {port.protocol || 'TCP'}
{port.node_port && ` (NodePort: ${port.node_port})`}
</span>
</div>
</div>
))}
{/* LoadBalancer Status */}
{service.loadBalancer?.ingress && service.loadBalancer.ingress.length > 0 && (
<div className="mt-2 p-2 bg-green-600/10 border border-green-500/30 rounded">
<p className="text-xs text-green-400 mb-2 font-medium">LoadBalancer Entries:</p>
{service.loadBalancer.ingress.map((ing, idx) => (
<div key={idx} className="flex items-center justify-between mb-1">
<span className="text-sm font-mono text-white">
{ing.ip || ing.hostname}
</span>
<div className="flex items-center gap-2">
{ing.ip && (
<>
<a
href={`http://${ing.ip}:${service.ports?.[0]?.port || 80}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 hover:bg-gray-700 rounded transition"
title="Open in browser"
>
<ExternalLink className="w-3 h-3 text-blue-400" />
</a>
<button
onClick={() => copyToClipboard(ing.ip!, "IP")}
className="p-1 hover:bg-gray-700 rounded transition"
>
{copiedText === ing.ip ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
)}
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
const renderIngress = (ingress: IngressEntry, index: number) => (
<div key={ingress.name || `ingress-${index}`} className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-semibold text-white">{ingress.name || `Ingress ${index + 1}`}</h4>
{ingress.class_name && (
<p className="text-xs text-gray-400 mt-1">Class: {ingress.class_name}</p>
)}
</div>
<Globe className="w-5 h-5 text-purple-400" />
</div>
<div className="space-y-2">
{ingress.rules?.map((rule, ruleIdx) => (
<div key={ruleIdx} className="bg-gray-900/50 rounded p-3 space-y-2">
{(() => {
const host = rule.host;
if (!host) return null;
return (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-white">{host}</span>
<div className="flex items-center gap-2">
<a
href={`https://${host}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 hover:bg-gray-700 rounded transition"
>
<ExternalLink className="w-3 h-3 text-blue-400" />
</a>
<button
onClick={() => copyToClipboard(host, "Host")}
className="p-1 hover:bg-gray-700 rounded transition"
>
{copiedText === host ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3 text-gray-400" />
)}
</button>
</div>
</div>
);
})()}
{rule.paths?.map((path, pathIdx) => {
const serviceName = path.backend?.service?.name || "service";
const servicePort = path.backend?.service?.port ?? "-";
return (
<div key={pathIdx} className="text-xs text-gray-400 ml-4">
{path.path || '/'} {serviceName}:{servicePort}
</div>
);
})}
</div>
))}
{ingress.tls && ingress.tls.length > 0 && (
<div className="mt-2 p-2 bg-blue-600/10 border border-blue-500/30 rounded">
<p className="text-xs text-blue-400 font-medium">🔒 TLS Enabled</p>
</div>
)}
</div>
</div>
);
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-700">
<div>
<h2 className="text-xl font-semibold text-white">Instance Entries</h2>
<p className="text-sm text-gray-400 mt-1">
{instance.name} ({instance.namespace})
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-800 rounded-lg transition"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-400">Loading entries...</span>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-400">{error}</p>
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
>
Close
</button>
</div>
) : entries ? (
<div className="space-y-6">
{/* Source Badge */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Data Source:</h3>
{getSourceBadge(entries.source)}
</div>
{/* Services */}
{entries.services && entries.services.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Network className="w-5 h-5 text-blue-400" />
<h3 className="text-lg font-semibold text-white">
Services ({entries.services.length})
</h3>
</div>
<div className="space-y-3">
{entries.services.map(renderService)}
</div>
</div>
)}
{/* Ingresses */}
{entries.ingresses && entries.ingresses.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-semibold text-white">
Ingresses ({entries.ingresses.length})
</h3>
</div>
<div className="space-y-3">
{entries.ingresses.map(renderIngress)}
</div>
</div>
)}
{/* Helm Notes (as fallback) */}
{entries.notes && entries.source === "notes" && (
<div>
<h3 className="text-lg font-semibold text-white mb-3">Helm Notes</h3>
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
{entries.notes}
</pre>
</div>
</div>
)}
{/* Empty State */}
{(!entries.services || entries.services.length === 0) &&
(!entries.ingresses || entries.ingresses.length === 0) &&
!entries.notes && (
<div className="text-center py-12">
<Network className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">No entries found for this instance</p>
<p className="text-xs text-gray-500 mt-2">Data source: {entries.source || 'unknown'}</p>
</div>
)}
</div>
) : null}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-700 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
>
Close
</button>
</div>
</div>
</div>
);
};

View File

@ -1,326 +0,0 @@
/**
* Instance Card Component
* Display instance information with action buttons
*/
import React from "react";
import {
Package,
Settings,
StopCircle,
RefreshCw,
CheckCircle,
XCircle,
Clock,
Network,
Box,
Calendar,
GitBranch,
Layers,
AlertTriangle,
History,
HelpCircle,
} from "lucide-react";
import type { InstanceResponse, InstanceStatus } from "@/api";
import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS } from "@/api";
interface InstanceCardProps {
instance: InstanceResponse;
onModify: (instance: InstanceResponse) => void;
onTerminate: (instance: InstanceResponse) => void;
onRefresh: (instance: InstanceResponse) => void;
onViewEntries: (instance: InstanceResponse) => void;
}
type StatusVisual = {
icon: React.ComponentType<{ className?: string }>;
color: string;
bg: string;
glow: string;
label: string;
defaultReason: string;
};
const STATUS_INFO_MAP: Record<InstanceStatus, StatusVisual> = {
[INSTANCE_STATUS.deployed]: {
icon: CheckCircle,
color: "text-emerald-400",
bg: "bg-gradient-to-r from-emerald-500/20 to-green-500/20 border-emerald-500/40",
glow: "shadow-emerald-500/20",
label: "Deployed",
defaultReason: "Deployment completed successfully.",
},
[INSTANCE_STATUS.failed]: {
icon: XCircle,
color: "text-rose-400",
bg: "bg-gradient-to-r from-rose-500/20 to-red-500/20 border-rose-500/40",
glow: "shadow-rose-500/20",
label: "Failed",
defaultReason: "Last operation reported a failure.",
},
[INSTANCE_STATUS["pending-install"]]: {
icon: Clock,
color: "text-amber-400",
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
glow: "shadow-amber-500/20",
label: "Pending Install",
defaultReason: "Installation is in progress.",
},
[INSTANCE_STATUS["pending-upgrade"]]: {
icon: Clock,
color: "text-amber-400",
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
glow: "shadow-amber-500/20",
label: "Pending Upgrade",
defaultReason: "Upgrade is in progress.",
},
[INSTANCE_STATUS["pending-rollback"]]: {
icon: Clock,
color: "text-amber-400",
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
glow: "shadow-amber-500/20",
label: "Pending Rollback",
defaultReason: "Rollback is in progress.",
},
[INSTANCE_STATUS["pending-delete"]]: {
icon: Clock,
color: "text-orange-400",
bg: "bg-gradient-to-r from-orange-500/20 to-red-500/20 border-orange-500/40",
glow: "shadow-orange-500/20",
label: "Pending Delete",
defaultReason: "Deletion is in progress.",
},
[INSTANCE_STATUS.superseded]: {
icon: History,
color: "text-indigo-300",
bg: "bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border-indigo-500/40",
glow: "shadow-indigo-500/20",
label: "Superseded",
defaultReason: "A newer revision has replaced this instance.",
},
[INSTANCE_STATUS.uninstalled]: {
icon: StopCircle,
color: "text-slate-300",
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-500/40",
glow: "shadow-slate-500/20",
label: "Uninstalled",
defaultReason: "Instance has been removed from the cluster.",
},
[INSTANCE_STATUS.unknown]: {
icon: HelpCircle,
color: "text-slate-300",
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-500/40",
glow: "shadow-slate-500/20",
label: "Unknown",
defaultReason: "Awaiting next state update.",
},
};
const LAST_OPERATION_LABELS: Record<string, string> = {
[INSTANCE_LAST_OPERATION.install]: "Install",
[INSTANCE_LAST_OPERATION.upgrade]: "Upgrade",
[INSTANCE_LAST_OPERATION.rollback]: "Rollback",
[INSTANCE_LAST_OPERATION.delete]: "Delete",
[INSTANCE_LAST_OPERATION.sync]: "Sync",
};
function toTitleCase(value: string): string {
return value
.split(/[\s-]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export const InstanceCard: React.FC<InstanceCardProps> = ({
instance,
onModify,
onTerminate,
onRefresh,
onViewEntries,
}) => {
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
const statusInfo =
STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown];
const StatusIcon = statusInfo.icon;
const statusLabel = statusInfo.label.toUpperCase();
const instanceName = instance.name || "Unnamed Instance";
const repository = instance.repository || "unknown";
const version = instance.version || "latest";
const namespace = instance.namespace || "default";
const revision = instance.revision ?? "-";
const createdAtText = instance.createdAt
? new Date(instance.createdAt).toLocaleDateString()
: "N/A";
const statusReason =
typeof instance.statusReason === "string" && instance.statusReason.trim().length > 0
? instance.statusReason.trim()
: statusInfo.defaultReason;
const rawOperation =
typeof instance.lastOperation === "string" ? instance.lastOperation.trim() : "";
const lastOperationLabel =
rawOperation.length > 0
? LAST_OPERATION_LABELS[rawOperation] ?? toTitleCase(rawOperation)
: null;
const lastError =
typeof instance.lastError === "string" ? instance.lastError.trim() : "";
return (
<div className="group relative bg-gradient-to-br from-slate-800/80 via-slate-800/50 to-slate-900/80 border border-slate-700/50 rounded-xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/10 transition-all duration-300 overflow-hidden">
{/* Decorative gradient overlay */}
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-500/5 to-purple-500/5 rounded-full blur-3xl -z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
{/* Header with enhanced design */}
<div className="relative px-6 py-5 border-b border-slate-700/50 bg-gradient-to-r from-slate-800/30 to-transparent">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
{/* Enhanced icon with glow effect */}
<div className="relative p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-xl border border-blue-500/30 shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/40 transition-shadow duration-300">
<Box className="w-7 h-7 text-blue-400" />
<div className="absolute inset-0 bg-blue-400/10 rounded-xl blur-sm"></div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-300 truncate">
{instanceName}
</h3>
<div className="flex items-center gap-2 mt-2">
<Package className="w-4 h-4 text-slate-400" />
<p className="text-sm text-slate-400 font-mono">
{repository}
</p>
<span className="text-slate-600"></span>
<span className="px-2 py-0.5 text-xs font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded">
{version}
</span>
</div>
</div>
</div>
{/* Enhanced Status Badge with glow */}
<div
className={`flex items-center gap-2 px-4 py-2 rounded-full border shadow-lg ${statusInfo.bg} ${statusInfo.glow} backdrop-blur-sm`}
>
<StatusIcon className={`w-4 h-4 ${statusInfo.color}`} />
<span className={`text-sm font-semibold ${statusInfo.color} uppercase tracking-wide`}>
{statusLabel}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-1 text-sm text-slate-300">
<span className="font-medium text-slate-200">{statusReason}</span>
{lastOperationLabel && (
<span className="text-xs uppercase tracking-wide text-slate-400">
Operation: {lastOperationLabel}
</span>
)}
</div>
</div>
{/* Enhanced Content Grid */}
<div className="relative px-6 py-5 space-y-4 bg-gradient-to-b from-transparent to-slate-900/30">
<div className="grid grid-cols-2 gap-4">
{/* Namespace */}
<div className="p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-purple-500/30 transition-colors">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-purple-400" />
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Namespace</p>
</div>
<p className="text-sm font-bold text-white">
{namespace}
</p>
</div>
{/* Revision */}
<div className="p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-green-500/30 transition-colors">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="w-4 h-4 text-green-400" />
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Revision</p>
</div>
<p className="text-sm font-bold text-white">
{revision}
</p>
</div>
{/* Repository - Full Width */}
<div className="col-span-2 p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-blue-500/30 transition-colors">
<div className="flex items-center gap-2 mb-2">
<Package className="w-4 h-4 text-blue-400" />
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Repository</p>
</div>
<p className="text-sm font-mono text-white truncate" title={repository}>
{repository}
</p>
</div>
{/* Launched Date - Full Width */}
<div className="col-span-2 p-3 bg-slate-800/50 border border-slate-700/50 rounded-lg hover:border-amber-500/30 transition-colors">
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-4 h-4 text-amber-400" />
<p className="text-xs text-slate-400 uppercase font-semibold tracking-wider">Launched</p>
</div>
<p className="text-sm font-bold text-white">
{createdAtText}
</p>
</div>
</div>
{lastError && (
<div className="flex items-start gap-3 p-4 border border-rose-500/30 bg-rose-500/10 rounded-lg">
<div className="p-2 bg-rose-500/20 rounded-lg border border-rose-500/40">
<AlertTriangle className="w-5 h-5 text-rose-300" />
</div>
<div>
<p className="text-sm font-semibold text-rose-200">Last error</p>
<p className="text-sm text-rose-100/90">{lastError}</p>
</div>
</div>
)}
</div>
{/* Enhanced Actions Bar */}
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-900/80 via-slate-900/50 to-slate-900/80 border-t border-slate-700/50 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => onRefresh(instance)}
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-slate-300 bg-slate-700/50 hover:bg-slate-600/50 rounded-lg transition-all duration-200 hover:scale-105 hover:shadow-lg border border-slate-600/50 hover:border-slate-500"
title="Refresh status"
>
<RefreshCw className="w-4 h-4 group-hover/btn:rotate-180 transition-transform duration-500" />
Refresh
</button>
<button
onClick={() => onViewEntries(instance)}
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-emerald-300 bg-gradient-to-r from-emerald-600/20 to-green-600/20 border border-emerald-500/40 rounded-lg hover:from-emerald-600/30 hover:to-green-600/30 hover:border-emerald-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-emerald-500/20"
title="View service entries"
>
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
Entries
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onModify(instance)}
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-blue-300 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 border border-blue-500/40 rounded-lg hover:from-blue-600/30 hover:to-cyan-600/30 hover:border-blue-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/20"
title="Modify instance configuration"
>
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
Modify
</button>
<button
onClick={() => onTerminate(instance)}
className="group/btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-rose-300 bg-gradient-to-r from-rose-600/20 to-red-600/20 border border-rose-500/40 rounded-lg hover:from-rose-600/30 hover:to-red-600/30 hover:border-rose-500/60 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-rose-500/20"
title="Terminate instance"
>
<StopCircle className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
Terminate
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,326 +0,0 @@
/**
* Modify Modal Component
* Modal for modifying an instance configuration
* Supports Values Schema for dynamic form generation
*/
import React, { useState, useEffect } from "react";
import { Settings } from "lucide-react";
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
import { getValuesSchema } from "@/api";
import {
Modal,
Button,
FormField,
Input,
Textarea,
Checkbox,
ErrorState,
LoadingState,
Badge,
SchemaFormGenerator
} from "@/shared/components";
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
interface ModifyModalProps {
instance: InstanceResponse;
onClose: () => void;
onConfirm: (clusterId: string, instanceId: string, data: UpdateInstanceRequest) => Promise<void>;
}
export const ModifyModal: React.FC<ModifyModalProps> = ({
instance,
onClose,
onConfirm,
}) => {
const [tag, setTag] = useState("");
const [description, setDescription] = useState("");
const [valuesYaml, setValuesYaml] = useState("");
const [wait, setWait] = useState(true);
const [timeout, setTimeout_] = useState(300);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Values Schema support
const [loadingSchema, setLoadingSchema] = useState(false);
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
const [formValues, setFormValues] = useState<Record<string, any>>({});
// Initialize with current values
useEffect(() => {
setTag(instance.version || "");
setDescription(""); // InstanceResponse doesn't have description field
// Parse existing values
if (instance.values) {
try {
const parsedValues = typeof instance.values === 'string'
? JSON.parse(instance.values)
: instance.values;
setFormValues(parsedValues);
setValuesYaml(typeof parsedValues === 'object' ? JSON.stringify(parsedValues, null, 2) : String(parsedValues));
} catch (err) {
console.error('[ModifyModal] Failed to parse existing values:', err);
setValuesYaml(String(instance.values) || "");
}
}
// Load values schema
loadValuesSchema();
}, [instance]);
const loadValuesSchema = async () => {
if (!instance.registryId || !instance.repository || !instance.version) {
setValuesSchema(null);
setInputMethod('yaml');
return;
}
setLoadingSchema(true);
try {
const schemaResponse = await getValuesSchema({
registryId: instance.registryId,
repositoryName: instance.repository,
reference: instance.version,
});
const normalizedSchema = extractJsonSchema(schemaResponse);
setValuesSchema(normalizedSchema);
if (normalizedSchema) {
setInputMethod('form');
console.log(`[ModifyModal] Loaded values schema with ${Object.keys(normalizedSchema.properties ?? {}).length} properties`);
} else {
setInputMethod('yaml');
console.log('[ModifyModal] No values schema available, using YAML input');
}
} catch (err) {
console.error('[ModifyModal] Failed to load values schema:', err);
setValuesSchema(null);
setInputMethod('yaml');
} finally {
setLoadingSchema(false);
}
};
const handleFormValuesChange = (values: Record<string, any>) => {
setFormValues(values);
// Also update YAML representation
setValuesYaml(JSON.stringify(values, null, 2));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const payload: UpdateInstanceRequest = {
version: tag && tag !== instance.version ? tag : undefined,
values: valuesYaml.trim() ? JSON.parse(valuesYaml) : undefined,
};
if (!instance.clusterId || !instance.id) {
setError("Instance identifier is missing");
setLoading(false);
return;
}
await onConfirm(instance.clusterId, instance.id, payload);
onClose();
} catch (err: unknown) {
if (err instanceof SyntaxError) {
setError("Invalid JSON/YAML values. Please fix the configuration.");
} else {
setError((err as Error).message || "Failed to modify instance");
}
} finally {
setLoading(false);
}
};
return (
<Modal
open={true}
onClose={onClose}
title={`Modify Instance - ${instance.name || "Unnamed"}`}
icon={Settings}
iconColor="text-blue-400"
size="lg"
footer={
<>
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
icon={Settings}
loading={loading}
onClick={handleSubmit}
>
{loading ? "Modifying..." : "Modify"}
</Button>
</>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error Alert */}
{error && (
<ErrorState message={error} title="Modification Failed" />
)}
{/* Current Info */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 space-y-2">
<p className="text-sm text-gray-300">
<span className="font-medium text-white">Current Version:</span> {instance.version || "N/A"}
</p>
<p className="text-sm text-gray-300">
<span className="font-medium text-white">Cluster:</span> {instance.clusterId || "N/A"}
</p>
<p className="text-sm text-gray-300">
<span className="font-medium text-white">Repository:</span> {instance.repository || "N/A"}
</p>
</div>
{/* Tag */}
<FormField
label="Version Tag"
required
help="Leave unchanged to keep current version"
>
<Input
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="e.g., v1.0.0"
required
/>
</FormField>
{/* Description */}
<FormField label="Description">
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Modification description"
/>
</FormField>
{/* Values Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-200">
Configuration Values
</label>
{valuesSchema?.properties && (
<div className="flex gap-2">
<button
onClick={() => setInputMethod('form')}
className="cursor-pointer"
>
<Badge
variant={inputMethod === 'form' ? 'success' : 'default'}
size="sm"
>
Form
</Badge>
</button>
<button
onClick={() => setInputMethod('yaml')}
className="cursor-pointer"
>
<Badge
variant={inputMethod === 'yaml' ? 'success' : 'default'}
size="sm"
>
YAML
</Badge>
</button>
</div>
)}
</div>
{loadingSchema ? (
<LoadingState message="Loading configuration schema..." />
) : inputMethod === 'form' && valuesSchema ? (
<SchemaFormGenerator
schema={valuesSchema}
values={formValues}
onChange={handleFormValuesChange}
/>
) : (
<Textarea
value={valuesYaml}
onChange={(e) => setValuesYaml(e.target.value)}
rows={12}
placeholder="key: value&#10;nested:&#10; key: value"
className="font-mono text-sm"
/>
)}
</div>
{/* Options */}
<div className="space-y-3">
<Checkbox
id="wait"
checked={wait}
onChange={(e) => setWait(e.target.checked)}
label="Wait for all resources to be ready"
/>
<FormField label="Timeout (seconds)">
<Input
type="number"
value={timeout}
onChange={(e) => setTimeout_(parseInt(e.target.value) || 300)}
min={60}
max={3600}
/>
</FormField>
</div>
</form>
</Modal>
);
};
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
typeof value === "object" && value !== null && !Array.isArray(value);
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
if (schemaResponse == null) {
return null;
}
const tryParse = (value: unknown): unknown => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return null;
}
}
return value;
};
let candidate: unknown = tryParse(schemaResponse);
if (candidate && typeof candidate === "object" && "schema" in (candidate as Record<string, unknown>)) {
const inner = (candidate as { schema?: unknown }).schema;
const normalizedInner = extractJsonSchema(inner);
if (normalizedInner) {
return normalizedInner;
}
}
if (isJsonSchemaObject(candidate)) {
return candidate as JsonSchema;
}
return null;
};

View File

@ -1,12 +0,0 @@
/**
* Service Instances Feature
* 服务实例管理功能
*/
// Export pages
export { default as InstancesManagementPage } from './pages/InstancesManagementPage';
// Export components
export { InstanceCard } from './components/InstanceCard';
export { ModifyModal } from './components/ModifyModal';

View File

@ -1,611 +0,0 @@
/**
* Instances Management Page
* Display and manage all Helm instances across clusters
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Package, RefreshCw, Boxes, Server } from "lucide-react";
import { listClusters, listInstances, deleteInstance, updateInstance } from "@/api";
import type { ClusterResponse, InstanceResponse, UpdateInstanceRequest } from "@/api";
import type { Cluster, Instance } from "@/core/types";
import {
PageHeader,
DropdownSelect,
Button,
LoadingState,
ErrorState,
EmptyState,
} from "@/shared/components";
import { useToast } from "@/shared";
import { InstanceErrors, SuccessMessages, formatApiError } from "@/shared/utils";
import { InstanceCard } from "../components/InstanceCard";
import { ModifyModal } from "../components/ModifyModal";
import { EntriesModal } from "../components/EntriesModal";
import { globalCache } from "@/shared/services/artifact-cache";
const AUTO_REFRESH_INTERVAL_MS = 30000;
type LoadDataMode = "initial" | "manual" | "auto";
interface LoadDataOptions {
skipCache?: boolean;
mode?: LoadDataMode;
}
const InstancesManagementPage: React.FC = () => {
const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
const [instancesByCluster, setInstancesByCluster] = useState<
Map<string, InstanceResponse[]>
>(new Map());
const [instanceTotals, setInstanceTotals] = useState<Map<string, number>>(new Map());
const [selectedCluster, setSelectedCluster] = useState<string>("all");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Modals
const [modifyInstance, setModifyInstance] = useState<Instance | null>(null);
const [entriesInstance, setEntriesInstance] = useState<Instance | null>(null);
// 核心数据加载函数 - 使用全局缓存
const loadDataCore = useCallback(async (options: LoadDataOptions = {}) => {
const { skipCache = false, mode = "initial" } = options;
const shouldShowLoading = mode === "initial";
const shouldShowRefreshing = mode === "manual";
if (shouldShowRefreshing) {
setRefreshing(true);
} else if (shouldShowLoading) {
setLoading(true);
}
setError(null);
try {
// Load clusters with cache
let clustersData: ClusterResponse[];
if (!skipCache) {
const cachedClusters = globalCache.get<ClusterResponse[]>('clusters');
if (cachedClusters) {
console.log('[InstancesManagementPage] Using cached clusters');
clustersData = cachedClusters;
} else {
clustersData = await listClusters();
globalCache.set('clusters', clustersData);
}
} else {
clustersData = await listClusters();
globalCache.set('clusters', clustersData);
}
const validClusters = clustersData.filter(
(cluster): cluster is ClusterResponse & { id: string } =>
typeof cluster.id === "string" && cluster.id.length > 0
);
setClusters(validClusters);
// Load instances for each cluster with cache
const instancesMap = new Map<string, InstanceResponse[]>();
const totalsMap = new Map<string, number>();
for (const cluster of validClusters) {
const clusterId = cluster.id;
try {
let normalized: NormalizedInstanceList;
if (!skipCache) {
const cachedInstances = globalCache.get<unknown>('instances', clusterId);
if (cachedInstances) {
normalized = normalizeInstanceList(cachedInstances);
console.log(`[InstancesManagementPage] Using cached instances for ${cluster.name}`);
if (!isNormalizedInstanceList(cachedInstances)) {
globalCache.set('instances', normalized, clusterId);
}
} else {
normalized = await fetchClusterInstances(clusterId);
}
} else {
normalized = await fetchClusterInstances(clusterId);
}
instancesMap.set(clusterId, normalized.instances);
totalsMap.set(clusterId, normalized.total);
} catch (err) {
console.error(`Failed to load instances for cluster ${cluster.name}:`, err);
instancesMap.set(clusterId, []);
totalsMap.set(clusterId, 0);
}
}
setInstancesByCluster(instancesMap);
setInstanceTotals(totalsMap);
return null; // 成功返回 null
} catch (err: unknown) {
const errorMsg = (err as Error).message || "Failed to load data";
setError(errorMsg);
return errorMsg; // 返回错误消息
} finally {
if (shouldShowRefreshing) {
setRefreshing(false);
} else if (shouldShowLoading) {
setLoading(false);
}
}
}, []); // 没有任何依赖!
// 初始加载 - 只执行一次
useEffect(() => {
let mounted = true;
const init = async () => {
const error = await loadDataCore({ skipCache: false, mode: "initial" });
if (mounted && error) {
toastError(error);
}
};
init();
return () => {
mounted = false;
};
}, [loadDataCore, toastError]); // loadDataCore 永远不会变化
// 手动刷新函数 - 带 toast 提示,清除缓存
const loadData = useCallback(async () => {
toastInfo("Refreshing instances...", {
title: "Instances Refresh",
durationMs: 1800,
mergeKey: "instances-refresh",
});
globalCache.clearType("instances");
const error = await loadDataCore({ skipCache: true, mode: "manual" }); // manual refresh mode
if (error) {
toastError(error);
} else {
success(SuccessMessages.DATA_REFRESHED);
}
}, [loadDataCore, toastError, toastInfo, success]);
const autoRefreshInFlight = useRef(false);
const autoRefresh = useCallback(async () => {
if (loading || refreshing || autoRefreshInFlight.current) {
return;
}
autoRefreshInFlight.current = true;
const error = await loadDataCore({ skipCache: true, mode: "auto" });
if (error) {
console.warn("[InstancesManagementPage] Auto refresh failed:", error);
}
autoRefreshInFlight.current = false;
}, [loading, refreshing, loadDataCore]);
useEffect(() => {
const intervalId = window.setInterval(() => {
void autoRefresh();
}, AUTO_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [autoRefresh]);
const handleRefresh = useCallback(async (instance: Instance) => {
const clusterId = instance.clusterId;
if (!clusterId) {
toastError("Cluster ID is missing");
return;
}
toastInfo(`Refreshing status for "${instance.name || "instance"}"...`, {
title: "Instance Status",
durationMs: 1800,
mergeKey: `instance-refresh-${instance.id || clusterId}`,
});
try {
const normalized = await fetchClusterInstances(clusterId);
setInstancesByCluster((prev) => {
const next = new Map(prev);
next.set(clusterId, normalized.instances);
return next;
});
setInstanceTotals((prev) => {
const next = new Map(prev);
next.set(clusterId, normalized.total);
return next;
});
success(SuccessMessages.INSTANCE_STATUS_REFRESHED);
} catch (err: unknown) {
toastError(formatApiError(err) || InstanceErrors.STATUS_FETCH_FAILED);
}
}, [success, toastError, toastInfo]);
const handleModify = useCallback((instance: Instance) => {
setModifyInstance(instance);
}, []);
const handleViewEntries = useCallback((instance: Instance) => {
setEntriesInstance(instance);
}, []);
const handleModifyConfirm = useCallback(async (
clusterId: string,
instanceId: string,
data: UpdateInstanceRequest
) => {
toastInfo("Applying instance update...", {
title: "Update Instance",
durationMs: 1800,
mergeKey: `instance-update-${instanceId}`,
});
try {
await updateInstance({ clusterId, instanceId }, data);
success(SuccessMessages.INSTANCE_UPGRADED);
await loadData();
} catch (err: unknown) {
const errorMsg = formatApiError(err) || InstanceErrors.UPDATE_FAILED;
toastError(errorMsg);
throw new Error(errorMsg);
}
}, [toastInfo, success, loadData, toastError]);
const handleTerminate = useCallback(async (instance: Instance) => {
if (
!confirm(
`Are you sure you want to terminate instance "${instance.name}"? This action cannot be undone.`
)
) {
return;
}
if (!instance.clusterId || !instance.id) {
toastError("Instance ID or Cluster ID is missing");
return;
}
try {
toastInfo(`Terminating "${instance.name || "instance"}"...`, {
title: "Terminate Instance",
durationMs: 1800,
mergeKey: `instance-terminate-${instance.id || instance.clusterId}`,
});
await deleteInstance({ clusterId: instance.clusterId, instanceId: instance.id });
success(SuccessMessages.INSTANCE_DELETED);
await loadData();
} catch (err: unknown) {
toastError(formatApiError(err) || InstanceErrors.DELETE_FAILED);
}
}, [success, toastError, loadData, toastInfo]);
// Get filtered instances - memoized to avoid recalculation on every render
const filteredInstances = useMemo((): Array<{ cluster: Cluster; instance: Instance }> => {
const result: Array<{ cluster: Cluster; instance: Instance }> = [];
clusters.forEach((cluster) => {
const clusterId = cluster.id;
if (!clusterId) {
return;
}
if (selectedCluster !== "all" && clusterId !== selectedCluster) {
return;
}
const instances = instancesByCluster.get(clusterId) || [];
instances.forEach((instance) => {
result.push({ cluster, instance });
});
});
return result;
}, [clusters, selectedCluster, instancesByCluster]);
// Calculate total instances - memoized
const totalInstances = useMemo(() => {
if (instanceTotals.size > 0) {
return Array.from(instanceTotals.values()).reduce((sum, total) => sum + total, 0);
}
return Array.from(instancesByCluster.values()).reduce(
(sum, instances) => sum + instances.length,
0
);
}, [instanceTotals, instancesByCluster]);
return (
<div className="p-6">
{/* Header */}
<PageHeader
title="Artifact - Instances"
description="Manage service instances across clusters"
icon={Boxes}
iconColor="text-green-400"
actions={
<Button
variant="secondary"
icon={RefreshCw}
onClick={loadData}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
}
/>
{/* Enhanced Stats with gradient cards */}
{!loading && clusters.length > 0 && (
<div className={`grid grid-cols-1 gap-5 mb-8 ${
clusters.length > 1 ? 'md:grid-cols-3' : 'md:grid-cols-2'
}`}>
<div className="relative group overflow-hidden bg-gradient-to-br from-blue-900/40 via-blue-800/30 to-blue-900/40 border border-blue-500/30 rounded-xl p-6 hover:border-blue-400/50 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-300">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-all"></div>
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-blue-300 uppercase tracking-wider mb-2">Total Instances</p>
<p className="text-4xl font-bold text-white">{totalInstances}</p>
</div>
<div className="p-4 bg-blue-500/20 rounded-xl border border-blue-400/30 shadow-lg shadow-blue-500/30">
<Package className="w-8 h-8 text-blue-400" />
</div>
</div>
</div>
<div className="relative group overflow-hidden bg-gradient-to-br from-emerald-900/40 via-emerald-800/30 to-green-900/40 border border-emerald-500/30 rounded-xl p-6 hover:border-emerald-400/50 hover:shadow-xl hover:shadow-emerald-500/20 transition-all duration-300">
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full blur-3xl group-hover:bg-emerald-500/20 transition-all"></div>
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-emerald-300 uppercase tracking-wider mb-2">Clusters</p>
<p className="text-4xl font-bold text-white">{clusters.length}</p>
</div>
<div className="p-4 bg-emerald-500/20 rounded-xl border border-emerald-400/30 shadow-lg shadow-emerald-500/30">
<Server className="w-8 h-8 text-emerald-400" />
</div>
</div>
</div>
{/* Only show Filtered when there are multiple clusters */}
{clusters.length > 1 && (
<div className="relative group overflow-hidden bg-gradient-to-br from-purple-900/40 via-purple-800/30 to-purple-900/40 border border-purple-500/30 rounded-xl p-6 hover:border-purple-400/50 hover:shadow-xl hover:shadow-purple-500/20 transition-all duration-300">
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/10 rounded-full blur-3xl group-hover:bg-purple-500/20 transition-all"></div>
<div className="relative flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-purple-300 uppercase tracking-wider mb-2">Showing</p>
<p className="text-4xl font-bold text-white">{filteredInstances.length}</p>
</div>
<div className="p-4 bg-purple-500/20 rounded-xl border border-purple-400/30 shadow-lg shadow-purple-500/30">
<Boxes className="w-8 h-8 text-purple-400" />
</div>
</div>
</div>
)}
</div>
)}
{/* Enhanced Filters */}
{clusters.length > 1 && (
<div className="mb-6 p-5 bg-gradient-to-r from-slate-800/50 via-slate-800/30 to-slate-800/50 border border-slate-700/50 rounded-xl">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-lg border border-cyan-500/30">
<Server className="w-5 h-5 text-cyan-400" />
</div>
<label className="text-sm font-semibold text-slate-300">
Filter by Cluster:
</label>
</div>
<DropdownSelect
value={selectedCluster}
onChange={(value) => setSelectedCluster(value)}
options={[
{ value: "all", label: "All Clusters" },
...clusters
.filter((cluster): cluster is ClusterResponse & { id: string } => Boolean(cluster.id))
.map((cluster) => {
const instanceCount = instancesByCluster.get(cluster.id)?.length || 0;
return {
value: cluster.id,
label: `${cluster.name || 'Unknown'} (${instanceCount} instances)`,
};
}),
]}
/>
</div>
</div>
)}
{/* Content */}
<div>
{loading ? (
<LoadingState message="Loading instances..." />
) : error ? (
<ErrorState
message={error}
onRetry={loadData}
/>
) : filteredInstances.length === 0 ? (
<EmptyState
icon={Package}
title="No instances found"
description="Launch your first service instance from Artifact Registries"
/>
) : (
<div className="space-y-6">
{/* Group by cluster if showing all */}
{selectedCluster === "all" ? (
clusters.map((cluster, index) => {
const clusterId = cluster.id;
if (!clusterId) return null;
const instances = instancesByCluster.get(clusterId) || [];
if (instances.length === 0) return null;
return (
<div key={clusterId || `cluster-${index}`} className="mb-8">
<div className="flex items-center gap-3 mb-5">
<div className="p-2.5 bg-gradient-to-br from-emerald-500/20 to-green-500/20 rounded-lg border border-emerald-500/30 shadow-lg shadow-emerald-500/10">
<Server className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">
{cluster.name || "Unnamed Cluster"}
</h2>
<p className="text-sm text-slate-400 mt-0.5">
{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{instances.map((instance) => (
<InstanceCard
key={instance.id}
instance={instance}
onModify={handleModify}
onTerminate={handleTerminate}
onRefresh={handleRefresh}
onViewEntries={handleViewEntries}
/>
))}
</div>
</div>
);
})
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filteredInstances.map(({ instance }) => (
<InstanceCard
key={instance.id}
instance={instance}
onModify={handleModify}
onTerminate={handleTerminate}
onRefresh={handleRefresh}
onViewEntries={handleViewEntries}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modals */}
{modifyInstance && (
<ModifyModal
instance={modifyInstance}
onClose={() => setModifyInstance(null)}
onConfirm={handleModifyConfirm}
/>
)}
{entriesInstance && (
<EntriesModal
instance={entriesInstance}
onClose={() => setEntriesInstance(null)}
/>
)}
</div>
);
};
export default InstancesManagementPage;
async function fetchClusterInstances(clusterId: string): Promise<NormalizedInstanceList> {
const response = await listInstances({ clusterId });
const normalized = normalizeInstanceList(response);
globalCache.set('instances', normalized, clusterId);
return normalized;
}
function normalizeInstanceList(raw: unknown): NormalizedInstanceList {
if (Array.isArray(raw)) {
return {
instances: raw as InstanceResponse[],
total: raw.length,
};
}
if (raw && typeof raw === "object") {
const payload = raw as InstanceListPayloadWithMeta;
const direct = extractInstancesFromPayload(payload);
if (direct) {
return direct;
}
if (payload.data !== undefined) {
const nested = normalizeInstanceList(payload.data);
return {
instances: nested.instances,
total: pickTotalValue(payload.total, payload.meta, nested.total),
};
}
}
return { instances: [], total: 0 };
}
function extractInstancesFromPayload(payload: InstanceListPayloadWithMeta): NormalizedInstanceList | null {
if (Array.isArray(payload.instances)) {
const instances = payload.instances as InstanceResponse[];
return {
instances,
total: pickTotalValue(payload.total, payload.meta, instances.length),
};
}
if (Array.isArray(payload.items)) {
const instances = payload.items as InstanceResponse[];
return {
instances,
total: pickTotalValue(payload.total, payload.meta, instances.length),
};
}
if (payload.data && typeof payload.data === "object") {
return extractInstancesFromPayload(payload.data as InstanceListPayloadWithMeta);
}
return null;
}
function pickTotalValue(totalValue: unknown, metaValue: unknown, fallback: number): number {
if (typeof totalValue === "number" && Number.isFinite(totalValue)) {
return totalValue;
}
const metaTotal = getMetaTotal(metaValue);
if (typeof metaTotal === "number") {
return metaTotal;
}
return fallback;
}
function getMetaTotal(metaValue: unknown): number | undefined {
if (metaValue && typeof metaValue === "object") {
const meta = metaValue as Record<string, unknown>;
if (typeof meta.total === "number") {
return meta.total;
}
if (typeof meta.count === "number") {
return meta.count;
}
}
return undefined;
}
function isNormalizedInstanceList(data: unknown): data is NormalizedInstanceList {
return (
!!data &&
typeof data === "object" &&
Array.isArray((data as NormalizedInstanceList).instances) &&
typeof (data as NormalizedInstanceList).total === "number"
);
}
type InstanceListPayloadWithMeta = {
instances?: unknown;
items?: unknown;
data?: unknown;
total?: unknown;
meta?: unknown;
};
interface NormalizedInstanceList {
instances: InstanceResponse[];
total: number;
}

View File

@ -1,396 +0,0 @@
/**
* Launch Modal Component
* Launch service instance from artifact
* Supports Values Schema for dynamic form generation
*/
import React, { useState, useEffect } from "react";
import { Rocket, AlertCircle, FileCode, FormInput } from "lucide-react";
import { useToast } from "@/shared";
import { createInstance, listClusters, getValuesSchema } from "@/api";
import type { CreateInstanceRequest, ClusterResponse } from "@/api";
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
import {
Modal,
Button,
FormField,
Input,
DropdownSelect,
Textarea,
LoadingState,
Badge,
SchemaFormGenerator
} from "@/shared/components";
import type { ArtifactCategory } from "../utils/artifactType";
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
interface LaunchModalProps {
isOpen: boolean;
onClose: () => void;
registryId: string;
repositoryName: string;
tag: string;
artifactType: ArtifactCategory;
}
export const LaunchModal: React.FC<LaunchModalProps> = ({
isOpen,
onClose,
registryId,
repositoryName,
tag,
artifactType,
}) => {
const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
const [loadingClusters, setLoadingClusters] = useState(false);
// Form fields
const [clusterId, setClusterId] = useState("");
const [namespace, setNamespace] = useState("default");
const [instanceName, setInstanceName] = useState("");
const [description, setDescription] = useState("");
// Values Schema support
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
const [loadingSchema, setLoadingSchema] = useState(false);
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
const [valuesForm, setValuesForm] = useState<Record<string, any>>({});
const [valuesYaml, setValuesYaml] = useState("");
// Load clusters and schema on mount
useEffect(() => {
if (isOpen) {
loadClusters();
loadValuesSchema();
}
}, [isOpen, registryId, repositoryName, tag]);
const loadClusters = async () => {
setLoadingClusters(true);
try {
const data = await listClusters();
setClusters(data);
const firstWithId = data.find((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
if (firstWithId?.id) {
setClusterId(firstWithId.id);
} else {
setClusterId("");
}
} catch (err) {
toastError(formatApiError(err) || ClusterErrors.LOAD_FAILED);
console.error(err);
} finally {
setLoadingClusters(false);
}
};
const loadValuesSchema = async () => {
setLoadingSchema(true);
try {
const schemaResponse = await getValuesSchema({ registryId, repositoryName, reference: tag });
const normalizedSchema = extractJsonSchema(schemaResponse);
setValuesSchema(normalizedSchema);
if (normalizedSchema) {
setInputMethod('form');
console.log(`[LaunchModal] Loaded values schema with ${Object.keys(normalizedSchema.properties ?? {}).length} properties`);
} else {
setInputMethod('yaml');
console.log('[LaunchModal] No values schema available, using YAML input');
}
} catch (err) {
console.error('[LaunchModal] Failed to load values schema:', err);
setValuesSchema(null);
setInputMethod('yaml');
} finally {
setLoadingSchema(false);
}
};
const resetFormState = () => {
setInstanceName("");
setDescription("");
setValuesYaml("");
setValuesForm({});
setNamespace("default");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!clusterId) {
toastError(ValidationErrors.REQUIRED_FIELD("Cluster"));
return;
}
if (!instanceName.trim()) {
toastError(ValidationErrors.REQUIRED_FIELD("Instance name"));
return;
}
if (!namespace.trim()) {
toastError(ValidationErrors.REQUIRED_FIELD("Namespace"));
return;
}
let valuesObj: Record<string, any> = {};
if (inputMethod === "form" && Object.keys(valuesForm).length > 0) {
valuesObj = valuesForm;
} else if (valuesYaml.trim()) {
try {
valuesObj = JSON.parse(valuesYaml.trim());
} catch {
toastError("Invalid YAML format. Please check your values.");
return;
}
}
const request: CreateInstanceRequest = {
name: instanceName.trim(),
namespace: namespace.trim(),
registryId,
repository: repositoryName,
tag,
...(Object.keys(valuesObj).length > 0 ? { values: valuesObj } : {}),
};
toastInfo("Launching instance...", {
title: "Launch Instance",
durationMs: 1800,
mergeKey: `instance-launch-${registryId}-${repositoryName}-${tag}`,
});
resetFormState();
onClose();
createInstance({ clusterId }, request)
.then(() => {
success(SuccessMessages.INSTANCE_DEPLOYED);
})
.catch((err) => {
toastError(formatApiError(err) || InstanceErrors.DEPLOY_FAILED);
console.error(err);
});
};
if (!isOpen) return null;
return (
<Modal
open={isOpen}
onClose={onClose}
title="Launch Instance"
icon={Rocket}
iconColor="text-green-400"
size="lg"
footer={
<>
<Button
type="button"
variant="secondary"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
variant="success"
icon={Rocket}
onClick={handleSubmit}
disabled={clusters.length === 0}
>
Launch
</Button>
</>
}
>
<div className="space-y-1 mb-4">
<p className="text-sm text-gray-400">
{repositoryName}:{tag}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Cluster Selection */}
<FormField label="Target Cluster" required>
{loadingClusters ? (
<div className="text-sm text-gray-500">Loading clusters...</div>
) : clusters.length === 0 ? (
<div className="flex items-center gap-2 p-3 bg-yellow-900/20 border border-yellow-700/50 rounded-lg text-yellow-300 text-sm">
<AlertCircle className="w-4 h-4" />
<span>No clusters available. Please add a cluster first.</span>
</div>
) : (
<DropdownSelect
value={clusterId}
onChange={(value) => setClusterId(value)}
options={clusters
.filter((cluster): cluster is ClusterResponse & { id: string } => Boolean(cluster.id))
.map((cluster) => ({
value: cluster.id,
label: cluster.name || cluster.id,
}))}
placeholder="Select a cluster"
required
/>
)}
</FormField>
{/* Instance Name */}
<FormField
label="Instance Name"
required
help="Lowercase alphanumeric characters, '-' or '.'"
>
<Input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="my-app"
required
/>
</FormField>
{/* Namespace */}
<FormField label="Namespace" required>
<Input
type="text"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
placeholder="default"
required
/>
</FormField>
{/* Description */}
<FormField label="Description">
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
/>
</FormField>
{/* Values Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300">
Configuration Values
</label>
{/* Input Method Toggle (only show if schema is available) */}
{valuesSchema?.properties && (
<div className="flex gap-1 bg-gray-800 rounded-lg p-1">
<button
type="button"
onClick={() => setInputMethod('form')}
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition ${
inputMethod === 'form'
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<FormInput className="w-3 h-3" />
Form
</button>
<button
type="button"
onClick={() => setInputMethod('yaml')}
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition ${
inputMethod === 'yaml'
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<FileCode className="w-3 h-3" />
YAML
</button>
</div>
)}
</div>
{loadingSchema ? (
<LoadingState message="Loading configuration schema..." size="sm" />
) : inputMethod === 'form' && valuesSchema ? (
<div className="border border-gray-700 rounded-lg p-4 max-h-96 overflow-y-auto bg-gray-900/30">
<SchemaFormGenerator
schema={valuesSchema}
values={valuesForm}
onChange={setValuesForm}
/>
</div>
) : (
<FormField
help={valuesSchema
? "Optional: Override default values with custom YAML configuration"
: "Optional: Chart does not provide a schema. Enter YAML configuration manually."
}
>
<Textarea
value={valuesYaml}
onChange={(e) => setValuesYaml(e.target.value)}
placeholder="# Enter custom values in YAML format&#10;# Example:&#10;# replicaCount: 3&#10;# image:&#10;# repository: myapp&#10;# tag: latest"
rows={8}
className="font-mono text-sm"
/>
</FormField>
)}
</div>
{/* Artifact Info */}
<div className="p-4 bg-gray-800/50 border border-gray-700 rounded-lg space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Repository:</span>
<span className="text-white font-mono">{repositoryName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Tag:</span>
<Badge variant="info" size="sm">{tag}</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Type:</span>
<span className="text-white">{artifactType}</span>
</div>
</div>
</form>
</Modal>
);
};
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
typeof value === "object" && value !== null && !Array.isArray(value);
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
if (schemaResponse == null) {
return null;
}
const tryParse = (value: unknown): unknown => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return null;
}
}
return value;
};
let candidate: unknown = tryParse(schemaResponse);
if (candidate && typeof candidate === "object" && "schema" in (candidate as Record<string, unknown>)) {
const inner = (candidate as { schema?: unknown }).schema;
const normalizedInner = extractJsonSchema(inner);
if (normalizedInner) {
return normalizedInner;
}
}
if (isJsonSchemaObject(candidate)) {
return candidate as JsonSchema;
}
return null;
};

View File

@ -1,183 +0,0 @@
/**
* Registry Tree Explorer Component
* Display registries in a tree-like structure
*/
import React, { useState } from "react";
import {
Database,
ChevronRight,
ChevronDown,
Package,
Server,
ExternalLink,
Shield,
Globe
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { AppRegistry } from "@/core/types";
interface RegistryTreeExplorerProps {
registries: AppRegistry[];
}
export const RegistryTreeExplorer: React.FC<RegistryTreeExplorerProps> = ({ registries }) => {
const navigate = useNavigate();
const [expandedRegistries, setExpandedRegistries] = useState<Set<string>>(new Set());
const [hoveredRegistry, setHoveredRegistry] = useState<string | null>(null);
const toggleRegistry = (registryId: string) => {
const newExpanded = new Set(expandedRegistries);
if (newExpanded.has(registryId)) {
newExpanded.delete(registryId);
} else {
newExpanded.add(registryId);
}
setExpandedRegistries(newExpanded);
};
const handleRegistryClick = (registryId: string) => {
navigate(`/artifact/registries/${registryId}`);
};
return (
<div className="mt-6">
<div className="relative bg-gradient-to-br from-slate-800/80 via-slate-800/50 to-slate-900/80 border border-slate-700/50 rounded-xl overflow-hidden shadow-2xl">
{/* Decorative background effect */}
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-purple-500/5 via-transparent to-blue-500/5 pointer-events-none"></div>
{/* Header with enhanced design */}
<div className="relative p-6 border-b border-slate-700/50 bg-gradient-to-r from-purple-900/20 via-transparent to-blue-900/20 backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-xl border border-purple-500/30 shadow-lg shadow-purple-500/20">
<Database className="w-6 h-6 text-purple-400" />
</div>
<div>
<h3 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-300">
OCI Registries
</h3>
<p className="text-sm text-slate-400 mt-0.5">
{registries.length} {registries.length === 1 ? 'registry' : 'registries'} configured
</p>
</div>
</div>
</div>
{/* Registry List */}
<div className="divide-y divide-slate-700/50">
{registries.map((registry) => {
const isExpanded = expandedRegistries.has(registry.id || '');
const isHovered = hoveredRegistry === registry.id;
return (
<div
key={registry.id}
className="relative group transition-all duration-300"
onMouseEnter={() => setHoveredRegistry(registry.id || '')}
onMouseLeave={() => setHoveredRegistry(null)}
>
{/* Hover glow effect */}
{isHovered && (
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-cyan-500/10 pointer-events-none"></div>
)}
<div
className="relative flex items-center justify-between p-5 cursor-pointer hover:bg-slate-800/50 transition-colors"
onClick={() => toggleRegistry(registry.id || '')}
>
<div className="flex items-center gap-4 flex-1">
{/* Expand/Collapse Icon */}
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-purple-400 transition-transform duration-200" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-purple-400 transition-colors duration-200" />
)}
</div>
{/* Registry Icon with glow */}
<div className={`relative p-3 rounded-xl border transition-all duration-300 ${
isExpanded
? 'bg-gradient-to-br from-purple-500/30 to-blue-500/30 border-purple-500/50 shadow-lg shadow-purple-500/30'
: 'bg-gradient-to-br from-purple-500/10 to-blue-500/10 border-purple-500/30 group-hover:border-purple-500/50'
}`}>
<Server className={`w-6 h-6 transition-colors duration-300 ${
isExpanded ? 'text-purple-300' : 'text-purple-400'
}`} />
</div>
{/* Registry Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h4 className="text-base font-bold text-white group-hover:text-purple-300 transition-colors">
{registry.name || 'Unnamed Registry'}
</h4>
{registry.url && (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-slate-700/50 border border-slate-600/50 rounded-md">
<Globe className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-xs text-cyan-400 font-medium">Connected</span>
</div>
)}
</div>
{registry.description && (
<p className="text-sm text-slate-400 mt-1.5 line-clamp-1">
{registry.description}
</p>
)}
{registry.url && (
<div className="flex items-center gap-2 mt-2">
<Shield className="w-3.5 h-3.5 text-slate-500" />
<p className="text-xs text-slate-500 font-mono truncate">
{registry.url}
</p>
</div>
)}
</div>
</div>
{/* Browse Button */}
<button
onClick={(e) => {
e.stopPropagation();
handleRegistryClick(registry.id || '');
}}
className="group/btn flex-shrink-0 inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-blue-300 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 hover:from-blue-600/30 hover:to-cyan-600/30 border border-blue-500/40 hover:border-blue-400/60 rounded-lg transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/30"
>
<Package className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
Browse
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover/btn:opacity-100 group-hover/btn:translate-x-0.5 group-hover/btn:-translate-y-0.5 transition-all" />
</button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="relative px-5 pb-5 pl-20 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="relative bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-slate-700/50 rounded-lg p-5 shadow-inner">
{/* Decorative corner accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-500/10 to-transparent rounded-bl-full pointer-events-none"></div>
<div className="relative flex items-start gap-3">
<div className="flex-shrink-0 p-2 bg-purple-500/10 border border-purple-500/30 rounded-lg">
<Package className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-white mb-1">
Ready to explore
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Click <span className="font-semibold text-blue-400">"Browse"</span> to view all repositories and artifacts available in this registry.
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@ -1,386 +0,0 @@
/**
* Repository Item Component
* Expandable to show tags list with type filtering
*/
import React, { useState, useEffect } from "react";
import { Package, ChevronDown, ChevronRight, Copy, Tag as TagIcon, Filter, AlertCircle, Rocket } from "lucide-react";
import { listArtifacts } from "@/api";
import type { ArtifactListItem } from "@/api";
import { useToast } from "@/shared";
import { LaunchModal } from "./LaunchModal";
import { artifactCache } from "@/shared/services/artifact-cache";
import { RegistryErrors, formatApiError } from "@/shared/utils";
import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactType";
interface RepositoryItemProps {
registryId: string;
registryName: string;
registryUrl: string;
repository: string;
}
export const RepositoryItem: React.FC<RepositoryItemProps> = ({
registryId,
registryName,
registryUrl,
repository,
}) => {
const { success, error: toastError } = useToast();
const [expanded, setExpanded] = useState(false);
const [allTags, setAllTags] = useState<ArtifactListItem[]>([]); // All tags
const [filteredTags, setFilteredTags] = useState<ArtifactListItem[]>([]); // Filtered tags
const [loading, setLoading] = useState(false);
const [tagsLoaded, setTagsLoaded] = useState(false); // Whether tags are loaded
const [filterType, setFilterType] = useState<ArtifactCategory | "all">("chart"); // Default filter: chart
const [loadError, setLoadError] = useState<string | null>(null); // Load error message
const [launchModalOpen, setLaunchModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<ArtifactListItem | null>(null);
// Load tags when expanded (using isMounted pattern for Strict Mode compatibility)
// 🚀 Enhanced with cache support and auto-retry
useEffect(() => {
if (!expanded || tagsLoaded) {
return; // Not expanded or already loaded, skip
}
const isMounted = { current: true };
const loadTags = async (attempt = 1) => {
const MAX_RETRIES = 2;
// 🚀 Check cache first
const cachedData = artifactCache.get(registryId, repository);
if (cachedData !== null) {
console.log(`[RepositoryItem] ✅ Cache hit for ${repository} (${cachedData.length} tags)`);
setAllTags(cachedData);
setTagsLoaded(true);
setLoadError(null);
return; // Skip API call
}
// Cache miss, load from API
setLoading(true);
setLoadError(null);
try {
console.log(`[RepositoryItem] 📡 Loading tags from API for: ${repository} (attempt ${attempt})`);
// Mark as loading in cache to prevent duplicate requests
artifactCache.setLoading(registryId, repository);
// Fetch all tags to support client-side filter switching
const tagList = await listArtifacts({ registryId, repositoryName: repository });
// Only update state if component is still mounted
if (isMounted.current) {
console.log(`[RepositoryItem] ✅ Loaded ${tagList.length} tags from API`);
// Update cache
artifactCache.set(registryId, repository, tagList);
setAllTags(tagList);
setTagsLoaded(true);
setLoadError(null);
}
} catch (error) {
// Only show error if component is still mounted
if (isMounted.current) {
// Retry if attempts remaining
if (attempt < MAX_RETRIES) {
console.log(`[RepositoryItem] 🔄 Retrying ${repository} (${attempt + 1}/${MAX_RETRIES})...`);
await new Promise(resolve => setTimeout(resolve, 500 * attempt)); // Exponential backoff
return loadTags(attempt + 1);
}
// All retries exhausted
console.error(`[RepositoryItem] ❌ All retries exhausted for ${repository}:`, error);
// Format error message using unified error handling
const errorMsg = formatApiError(error) || RegistryErrors.TAGS_LOAD_FAILED;
setLoadError(errorMsg);
toastError(errorMsg);
// 🔧 Clear cache to allow manual retry
artifactCache.clear(registryId, repository);
setAllTags([]);
// Don't set tagsLoaded to true - allow retry
}
} finally {
if (isMounted.current) {
setLoading(false);
}
}
};
loadTags();
// Cleanup: mark as unmounted to prevent state updates
return () => {
isMounted.current = false;
};
}, [expanded, tagsLoaded, registryId, repository, toastError]);
// Filter tags by type
useEffect(() => {
if (filterType === "all") {
setFilteredTags(allTags);
return;
}
setFilteredTags(
allTags.filter((tagItem) => inferArtifactCategory(tagItem) === filterType),
);
}, [allTags, filterType]);
// Get plural form of type name
const getTypeName = (type: string): string => {
if (type === "all") return "artifacts";
// Convert to plural form
const typeMap: Record<string, string> = {
chart: "charts",
image: "images",
other: "others",
};
return typeMap[type] || type;
};
const handleCopyPullCommand = (tagName?: string, tagType?: ArtifactCategory) => {
const baseUrl = registryUrl.replace(/^https?:\/\//, "");
let pullCommand: string;
if (tagType === "chart") {
// Helm Chart - use helm pull
pullCommand = tagName
? `helm pull oci://${baseUrl}/${repository} --version ${tagName}`
: `helm pull oci://${baseUrl}/${repository}`;
} else if (tagType === "image") {
// Docker Image - use docker pull
pullCommand = tagName
? `docker pull ${baseUrl}/${repository}:${tagName}`
: `docker pull ${baseUrl}/${repository}`;
} else {
// Other types - generic format
pullCommand = `${baseUrl}/${repository}${tagName ? `:${tagName}` : ""}`;
}
navigator.clipboard.writeText(pullCommand);
success("Pull command copied to clipboard");
};
const handleToggle = () => {
setExpanded(!expanded);
};
const handleLaunch = (tagItem: ArtifactListItem) => {
setSelectedTag(tagItem);
setLaunchModalOpen(true);
};
const handleCloseLaunchModal = () => {
setLaunchModalOpen(false);
setSelectedTag(null);
};
return (
<>
{/* Launch Modal */}
{selectedTag && selectedTag.repositoryName && selectedTag.tag && (
<LaunchModal
isOpen={launchModalOpen}
onClose={handleCloseLaunchModal}
registryId={registryId}
repositoryName={selectedTag.repositoryName}
tag={selectedTag.tag}
artifactType={inferArtifactCategory(selectedTag)}
/>
)}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg overflow-hidden">
{/* Repository Header */}
<div className="flex items-center justify-between p-3 hover:bg-gray-800 transition group">
{/* Left: Repository Info */}
<div
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
onClick={handleToggle}
>
{/* Expand/Collapse Icon */}
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
)}
<Package className="w-4 h-4 text-blue-400 flex-shrink-0" />
<span className="text-sm text-gray-200 font-mono truncate" title={repository}>
{repository}
</span>
{/* Registry Name Badge */}
<span className="px-2 py-0.5 bg-purple-600/20 border border-purple-500/30 rounded text-xs text-purple-300 flex-shrink-0">
🏷 {registryName}
</span>
{/* Tag Count Badge */}
{allTags.length > 0 && (
<span className="px-2 py-0.5 bg-blue-600/20 border border-blue-500/30 rounded text-xs text-blue-300">
{filteredTags.length}/{allTags.length} {getTypeName(filterType)}
</span>
)}
</div>
{/* Right: Action Buttons */}
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition">
<button
onClick={(e) => {
e.stopPropagation();
// 使用第一个tag的类型作为默认类型
const firstTagType = allTags.length > 0 ? inferArtifactCategory(allTags[0]) : undefined;
handleCopyPullCommand(undefined, firstTagType);
}}
className="p-1.5 hover:bg-gray-700 rounded transition"
title="Copy pull command"
>
<Copy className="w-3.5 h-3.5 text-gray-400 hover:text-blue-400" />
</button>
</div>
</div>
{/* Tags List (shown when expanded) */}
{expanded && (
<div className="border-t border-gray-700 bg-gray-900/50 p-3">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<p className="text-xs text-gray-500">Loading tags...</p>
</div>
</div>
) : (
<>
{/* Filter */}
{allTags.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-gray-700">
<Filter className="w-3.5 h-3.5 text-gray-400" />
<span className="text-xs text-gray-400">Type:</span>
<div className="flex gap-1">
<button
onClick={() => setFilterType("chart")}
className={`px-2 py-0.5 text-xs rounded transition ${
filterType === "chart"
? "bg-green-600 text-white"
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
}`}
>
Chart
</button>
<button
onClick={() => setFilterType("image")}
className={`px-2 py-0.5 text-xs rounded transition ${
filterType === "image"
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
}`}
>
Image
</button>
<button
onClick={() => setFilterType("other")}
className={`px-2 py-0.5 text-xs rounded transition ${
filterType === "other"
? "bg-purple-600 text-white"
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
}`}
>
Other
</button>
<button
onClick={() => setFilterType("all")}
className={`px-2 py-0.5 text-xs rounded transition ${
filterType === "all"
? "bg-gray-600 text-white"
: "bg-gray-700 text-gray-400 hover:bg-gray-600"
}`}
>
All
</button>
</div>
</div>
)}
{/* Error State */}
{loadError ? (
<div className="text-center py-4 text-red-400 text-sm">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p>Failed to load artifacts</p>
<p className="text-xs text-gray-500 mt-1">{loadError}</p>
</div>
) : filteredTags.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
<TagIcon className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p>No {filterType === "all" ? "" : getTypeName(filterType)} tags</p>
</div>
) : (
<div className="space-y-1.5">
{filteredTags.map((tagItem, index) => {
const category = inferArtifactCategory(tagItem);
return (
<div
key={`${tagItem.tag}-${index}`}
className="flex items-center justify-between p-2 bg-gray-800/50 hover:bg-gray-800 rounded transition group/tag"
>
{/* Tag Info */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<TagIcon className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
<span className="text-sm text-gray-300 font-mono truncate" title={tagItem.tag}>
{tagItem.tag}
</span>
{/* Artifact Type Badge */}
<span
className={`px-1.5 py-0.5 text-xs rounded ${
category === "chart"
? "bg-green-600/20 text-green-300 border border-green-500/30"
: category === "image"
? "bg-blue-600/20 text-blue-300 border border-blue-500/30"
: "bg-purple-600/20 text-purple-300 border border-purple-500/30"
}`}
title={tagItem.mediaType || tagItem.type}
>
{category}
</span>
{/* Artifact Size */}
{tagItem.size && tagItem.size > 0 && (
<span className="text-xs text-gray-500">
{(tagItem.size / 1024 / 1024).toFixed(2)} MB
</span>
)}
</div>
{/* Tag Action Buttons */}
<div className="flex items-center gap-1 opacity-0 group-hover/tag:opacity-100">
{/* Launch button - only for chart/helm type */}
{category === "chart" && (
<button
onClick={() => handleLaunch(tagItem)}
className="p-1 hover:bg-green-700/50 rounded transition"
title={`Launch ${tagItem.tag}`}
>
<Rocket className="w-3 h-3 text-green-400 hover:text-green-300" />
</button>
)}
<button
onClick={() => handleCopyPullCommand(tagItem.tag, category)}
className="p-1 hover:bg-gray-700 rounded transition"
title={`Copy pull command for ${tagItem.tag}`}
>
<Copy className="w-3 h-3 text-gray-400 hover:text-blue-400" />
</button>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
)}
</div>
</>
);
};

View File

@ -1,144 +0,0 @@
/**
* Tag Card Component
* Simple card for displaying a single tag/artifact
*/
import React, { useState } from "react";
import { Package, Rocket, Copy, HardDrive } from "lucide-react";
import { LaunchModal } from "./LaunchModal";
import { useToast } from "@/shared";
import type { ArtifactListItem } from "@/api";
import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactType";
interface TagCardProps {
registryId: string;
tag: ArtifactListItem;
}
export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
const { success } = useToast();
const [launchModalOpen, setLaunchModalOpen] = useState(false);
const category = inferArtifactCategory(tag);
const handleLaunch = () => {
setLaunchModalOpen(true);
};
const handleCopy = () => {
const tagName = tag.tag || '';
if (!tagName || !tag.repositoryName) return;
const pullCommand = `helm pull oci://${tag.repositoryName}:${tagName}`;
navigator.clipboard.writeText(pullCommand);
success("Pull command copied to clipboard!");
};
const formatSize = (bytes: number) => {
if (bytes === 0) return "N/A";
const mb = bytes / (1024 * 1024);
if (mb < 1) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${mb.toFixed(1)} MB`;
};
const getTypeColor = (type: ArtifactCategory) => {
switch (type) {
case "chart":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
case "image":
return "text-green-400 bg-green-500/10 border-green-500/30";
default:
return "text-gray-400 bg-gray-500/10 border-gray-500/30";
}
};
const getTypeIcon = (type: ArtifactCategory) => {
switch (type) {
case "chart":
return "📦";
case "image":
return "🐳";
default:
return "📄";
}
};
return (
<>
<div className="bg-dark-card border border-dark-border rounded-lg p-4 hover:border-brand-blue/50 transition-all group">
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex-shrink-0">
<div className={`w-10 h-10 rounded-lg border ${getTypeColor(category)}
flex items-center justify-center text-lg`}>
{getTypeIcon(category)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Tag name */}
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-purple-400 flex-shrink-0" />
<h3 className="text-sm font-semibold text-white truncate">
{tag.tag || 'N/A'}
</h3>
<span
className={`px-2 py-0.5 rounded text-xs border ${getTypeColor(category)}`}
title={tag.mediaType || tag.type || ''}
>
{category}
</span>
</div>
{/* Repository path */}
<p className="text-xs text-gray-500 truncate mb-2">
{tag.repositoryName}
</p>
{/* Size */}
<div className="flex items-center gap-2 text-xs text-gray-400">
<HardDrive className="w-3.5 h-3.5" />
<span>{formatSize(tag.size || 0)}</span>
</div>
</div>
{/* Actions */}
<div className="flex-shrink-0 flex flex-col gap-2">
<button
onClick={handleLaunch}
className="px-3 py-1.5 bg-brand-blue hover:bg-brand-blue/80 text-white rounded
text-xs font-medium transition-colors flex items-center gap-1.5"
title="Launch this artifact"
>
<Rocket className="w-3.5 h-3.5" />
<span>Launch</span>
</button>
<button
onClick={handleCopy}
className="px-3 py-1.5 bg-dark-lighter hover:bg-white/5 text-gray-300
border border-dark-border rounded text-xs transition-colors flex items-center gap-1.5"
title="Copy pull command"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</button>
</div>
</div>
</div>
{/* Launch Modal */}
{launchModalOpen && tag.repositoryName && tag.tag && (
<LaunchModal
isOpen={launchModalOpen}
onClose={() => setLaunchModalOpen(false)}
registryId={registryId}
repositoryName={tag.repositoryName}
tag={tag.tag}
artifactType={category}
/>
)}
</>
);
};

View File

@ -1,13 +0,0 @@
/**
* Registry Browser Feature (Flattened Architecture)
* OCI 仓库浏览功能(扁平化架构)
*/
// Export pages
export { default as RegistriesBrowserPage } from './pages/RegistriesBrowserPage';
// Export components
export { RepositoryItem } from './components/RepositoryItem';
export { TagCard } from './components/TagCard';
export { LaunchModal } from './components/LaunchModal';

View File

@ -1,511 +0,0 @@
/**
* Artifact Browser Page
* 左侧 Registry/Repository 树(自动加载并展开),右侧 Artifact 卡片。
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Package,
Database,
RefreshCw,
Filter,
Search,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { useToast } from "@/shared";
import {
Button,
LoadingState,
EmptyState,
Badge,
} from "@/shared/components";
import {
listRegistries,
listRepositories,
listArtifacts,
} from "@/api";
import type {
RegistryResponse,
ListRepositories200Item,
ArtifactListItem,
ListArtifactsFilter,
} from "@/api";
import { TagCard } from "../components/TagCard";
import { globalCache } from "@/shared/services/artifact-cache";
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
interface RepositoryNode {
name: string;
registryId: string;
registryName: string;
artifactCount?: number;
}
interface RegistryNode {
registry: RegistryResponse;
repositories: RepositoryNode[];
expanded: boolean;
}
const FILTER_OPTIONS: Array<{ value: ListArtifactsFilter | undefined; label: string }> = [
{ value: undefined, label: "All" },
{ value: "chart", label: "Charts" },
{ value: "image", label: "Images" },
{ value: "other", label: "Other" },
];
const ArtifactBrowserPage: React.FC = () => {
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
const [registryNodes, setRegistryNodes] = useState<RegistryNode[]>([]);
const [loadingRegistries, setLoadingRegistries] = useState(true);
const [loadingRepositories, setLoadingRepositories] = useState(true);
const [repositoryError, setRepositoryError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [selectedRepository, setSelectedRepository] = useState<RepositoryNode | null>(null);
const [artifacts, setArtifacts] = useState<ArtifactListItem[]>([]);
const [loadingArtifacts, setLoadingArtifacts] = useState(false);
const [artifactError, setArtifactError] = useState<string | null>(null);
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>(undefined);
const [searchTerm, setSearchTerm] = useState("");
const loadArtifacts = useCallback(
async (
registryId: string,
repositoryName: string,
skipCache = false,
isRefresh = false
) => {
if (!isRefresh) {
setLoadingArtifacts(true);
}
setArtifactError(null);
const filterKey = filter ?? "all";
try {
let data: ArtifactListItem[] | null = null;
if (!skipCache) {
data = globalCache.get<ArtifactListItem[]>("tags", registryId, repositoryName, filterKey);
}
if (!data) {
data = await listArtifacts(
{ registryId, repositoryName },
filter ? { filter } : undefined
);
globalCache.set("tags", data, registryId, repositoryName, filterKey);
}
setArtifacts(data ?? []);
} catch (err) {
const errorMsg = formatApiError(err) || "Failed to load artifacts";
setArtifactError(errorMsg);
toastError(errorMsg);
} finally {
if (!isRefresh) {
setLoadingArtifacts(false);
}
}
},
[filter, toastError]
);
const fetchRegsAndRepos = useCallback(async (isRefresh = false) => {
if (!isRefresh) {
setLoadingRegistries(true);
setLoadingRepositories(true);
}
setRepositoryError(null);
let succeeded = false;
try {
let registries = globalCache.get<RegistryResponse[]>("registries");
if (!registries) {
registries = await listRegistries();
globalCache.set("registries", registries);
}
const baseNodes: RegistryNode[] = registries.map((registry) => ({
registry,
repositories: [],
expanded: true,
}));
setRegistryNodes(baseNodes);
if (!isRefresh) {
setLoadingRegistries(false);
}
const repoMap = await Promise.all(
registries.map(async (registry) => {
const registryId = registry.id;
if (!registryId) {
return { id: registryId, repos: [] };
}
try {
let repoNodes = globalCache.get<RepositoryNode[]>("repositories", registryId);
if (!repoNodes) {
const response = await listRepositories({ registryId });
repoNodes = normalizeRepositories(registry, response);
globalCache.set("repositories", repoNodes, registryId);
}
return { id: registryId, repos: repoNodes };
} catch (err) {
console.error(`Failed to load repositories for ${registry.name}`, err);
return { id: registryId, repos: [] };
}
})
);
setRegistryNodes((prev) =>
prev.map((node) => {
const mapping = repoMap.find((entry) => entry.id === node.registry.id);
if (!mapping) return node;
return { ...node, repositories: mapping.repos };
})
);
const firstRepo = repoMap.find((entry) => entry.repos.length > 0)?.repos[0];
if (firstRepo) {
setSelectedRepository(firstRepo);
await loadArtifacts(firstRepo.registryId, firstRepo.name, false, isRefresh);
} else {
setSelectedRepository(null);
setArtifacts([]);
}
succeeded = true;
} catch (err) {
const msg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
toastError(msg);
setRepositoryError(msg);
} finally {
if (!isRefresh) {
setLoadingRepositories(false);
}
}
return succeeded;
}, [toastError, loadArtifacts]);
const handleRefresh = async () => {
if (refreshing) return;
toastInfo("Refreshing registries & repositories...", {
title: "Artifact Browser Refresh",
durationMs: 1800,
mergeKey: "artifact-browser-refresh",
});
setRefreshing(true);
globalCache.clearAll();
try {
const refreshed = await fetchRegsAndRepos(true);
if (refreshed) {
toastSuccess(SuccessMessages.DATA_REFRESHED);
}
} finally {
setRefreshing(false);
}
};
const handleRepositoryClick = (repo: RepositoryNode) => {
setSelectedRepository(repo);
loadArtifacts(repo.registryId, repo.name, true);
};
const toggleRegistry = (registryId?: string) => {
if (!registryId) return;
setRegistryNodes((prev) =>
prev.map((node) =>
node.registry.id === registryId ? { ...node, expanded: !node.expanded } : node
)
);
};
const hasInitialized = React.useRef(false);
useEffect(() => {
if (hasInitialized.current) {
return;
}
hasInitialized.current = true;
fetchRegsAndRepos();
}, [fetchRegsAndRepos]);
useEffect(() => {
if (selectedRepository) {
loadArtifacts(selectedRepository.registryId, selectedRepository.name);
}
}, [filter, selectedRepository, loadArtifacts]);
const filteredNodes = useMemo(() => {
const term = searchTerm.toLowerCase();
if (!term) return registryNodes;
return registryNodes
.map((node) => ({
...node,
repositories: node.repositories.filter((repo) => {
const registryMatch = node.registry.name?.toLowerCase().includes(term);
const repoMatch = repo.name.toLowerCase().includes(term);
return registryMatch || repoMatch;
}),
}))
.filter(
(node) =>
node.repositories.length > 0 ||
node.registry.name?.toLowerCase().includes(term)
);
}, [registryNodes, searchTerm]);
const selectedRegistryName = selectedRepository
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
.name
: null;
return (
<div className="h-[calc(100vh-8rem)] -m-6 flex flex-col">
<div className="flex-shrink-0 border-b border-dark-border bg-dark-card px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-purple-400" />
<div>
<h1 className="text-xl font-semibold text-white">Artifact Browser</h1>
<p className="text-sm text-gray-400">
Browse registries, repositories, and artifacts
</p>
</div>
</div>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon
>
Refresh
</Button>
</div>
</div>
<div className="flex-1 flex overflow-hidden bg-dark-bg">
<aside className="w-80 border-r border-dark-border bg-gradient-to-b from-gray-900 via-gray-950 to-gray-900 flex flex-col">
<div className="p-4 border-b border-dark-border space-y-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search registries / repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-900/70 border border-gray-700 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{repositoryError && (
<p className="text-xs text-red-400">{repositoryError}</p>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
<span>Registries</span>
<Badge variant="secondary">{registryNodes.length}</Badge>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{loadingRegistries ? (
<div className="p-4">
<LoadingState message="Loading registries..." />
</div>
) : filteredNodes.length === 0 ? (
<div className="p-4">
<EmptyState
icon={Database}
title="No registries"
description="Add a registry to get started."
/>
</div>
) : (
filteredNodes.map((node) => (
<div key={node.registry.id || node.registry.name}>
<button
onClick={() => toggleRegistry(node.registry.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-800/60 transition"
>
<div className="flex items-center gap-2">
{node.expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
<Database className="w-4 h-4 text-purple-400" />
<div className="text-left">
<p className="text-sm text-white">{node.registry.name || "Unnamed"}</p>
<p className="text-[11px] text-gray-500 truncate">
{node.registry.url}
</p>
</div>
</div>
<Badge variant="secondary">{node.repositories.length}</Badge>
</button>
{node.expanded && (
<div className="bg-gray-900/60">
{node.repositories.length === 0 ? (
<p className="px-8 py-3 text-xs text-gray-500">
{loadingRepositories
? "Loading repositories..."
: "No repositories found."}
</p>
) : (
node.repositories.map((repo) => {
const isSelected =
selectedRepository?.registryId === repo.registryId &&
selectedRepository?.name === repo.name;
return (
<button
key={`${repo.registryId}-${repo.name}`}
onClick={() => handleRepositoryClick(repo)}
className={`w-full text-left px-8 py-2 flex items-center justify-between text-sm transition ${
isSelected
? "bg-purple-600/20 text-white"
: "hover:bg-gray-800/80 text-gray-300"
}`}
>
<span className="truncate">{repo.name}</span>
{repo.artifactCount !== undefined && (
<span className="text-xs text-gray-500">
{repo.artifactCount}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
))
)}
</div>
</aside>
<main className="flex-1 flex flex-col bg-dark-card overflow-hidden">
{!selectedRepository ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Package}
title="Select a repository"
description="Choose a repository from the left panel to view artifacts."
/>
</div>
) : (
<>
<div className="flex-shrink-0 border-b border-dark-border p-5 bg-gradient-to-r from-gray-900 to-gray-850">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500">Repository</p>
<h2 className="text-2xl font-semibold text-white">
{selectedRepository.name}
</h2>
<p className="text-sm text-gray-400">
{selectedRegistryName || selectedRepository.registryId}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-gray-400" />
{FILTER_OPTIONS.map((option) => (
<button
key={option.label}
onClick={() => setFilter(option.value)}
className={`px-3 py-1.5 text-xs rounded-full border transition ${
filter === option.value
? "bg-purple-600 text-white border-purple-500"
: "border-gray-700 text-gray-300 hover:border-gray-500"
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">
{artifactError && (
<p className="text-sm text-red-400 mb-3">{artifactError}</p>
)}
{loadingArtifacts ? (
<LoadingState message="Loading artifacts..." />
) : artifacts.length === 0 ? (
<EmptyState
icon={Package}
title="No artifacts"
description={
filter
? `No ${filter} artifacts found for this repository.`
: "This repository doesn't contain any artifacts yet."
}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{artifacts.map((artifact, index) => (
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={selectedRepository.registryId}
tag={artifact}
/>
))}
</div>
)}
</div>
</>
)}
</main>
</div>
</div>
);
};
export default ArtifactBrowserPage;
function normalizeRepositories(
registry: RegistryResponse,
payload: ListRepositories200Item[] | { repositories?: string[] } | null | undefined
): RepositoryNode[] {
const registryId = registry.id || "";
const registryName = registry.name || registryId;
if (Array.isArray(payload)) {
return payload
.map((repo): RepositoryNode | null => {
if (typeof repo === "string") {
return {
name: repo,
registryId,
registryName,
artifactCount: undefined,
};
}
const name = repo.name || "";
if (!name) {
return null;
}
return {
name,
registryId,
registryName,
artifactCount: (repo as any).artifact_count ?? (repo as any).artifactCount,
};
})
.filter((repo): repo is RepositoryNode => Boolean(repo));
}
if (payload && typeof payload === "object" && Array.isArray((payload as any).repositories)) {
const repoEntries = (payload as any).repositories.map(
(name: unknown): RepositoryNode | null =>
typeof name === "string"
? {
name,
registryId,
registryName,
artifactCount: undefined,
}
: null
);
return repoEntries.filter(
(repo: RepositoryNode | null): repo is RepositoryNode => Boolean(repo)
);
}
return [];
}

View File

@ -1,270 +0,0 @@
/**
* Artifacts Browser Page
* 浏览特定 Repository 的所有 Artifacts (Tags)
*/
import React, { useState, useEffect } from "react";
import { Package, RefreshCw, ArrowLeft, Filter } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useToast } from "@/shared";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
EmptyState,
Badge
} from "@/shared/components";
import { listArtifacts, listRegistries } from "@/api";
import type { ArtifactListItem, ListArtifactsFilter } from "@/api";
import { TagCard } from "../components/TagCard";
import { globalCache } from "@/shared/services/artifact-cache";
import { SuccessMessages, formatApiError } from "@/shared/utils";
const ArtifactsBrowserPage: React.FC = () => {
const { registryId, repositoryName } = useParams<{ registryId: string; repositoryName: string }>();
const navigate = useNavigate();
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
const [artifacts, setArtifacts] = useState<ArtifactListItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>(undefined);
const [registryName, setRegistryName] = useState<string>("");
// Decode repository name (URL encoded)
const decodedRepositoryName = repositoryName ? decodeURIComponent(repositoryName) : "";
// Load registry info
useEffect(() => {
const loadRegistryInfo = async () => {
if (!registryId) return;
try {
const registries = await listRegistries();
const registry = registries.find(r => r.id === registryId);
if (registry) {
setRegistryName(registry.name || "");
}
} catch (err) {
console.error("Failed to load registry info:", err);
}
};
loadRegistryInfo();
}, [registryId]);
// Load artifacts
const loadArtifacts = async (isMounted = { current: true }, skipCache = false, isRefresh = false) => {
if (!registryId || !decodedRepositoryName) {
setError("Registry ID or Repository Name is missing");
setLoading(false);
return false;
}
const filterKey = filter ?? "all";
if (!skipCache) {
const cached = globalCache.get<ArtifactListItem[]>(
"tags",
registryId,
decodedRepositoryName,
filterKey
);
if (cached) {
console.log(`[ArtifactsBrowserPage] Using cached artifacts for ${decodedRepositoryName}`);
setArtifacts(cached);
setLoading(false);
return true;
}
}
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
let succeeded = false;
try {
const data = await listArtifacts(
{ registryId, repositoryName: decodedRepositoryName },
filter ? { filter } : undefined
);
if (isMounted.current) {
setArtifacts(data);
globalCache.set("tags", data, registryId, decodedRepositoryName, filterKey);
succeeded = true;
}
} catch (err) {
if (isMounted.current) {
const errorMsg = formatApiError(err) || "Failed to load artifacts";
setError(errorMsg);
toastError(errorMsg);
console.error(err);
}
} finally {
if (isMounted.current) {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadArtifacts(isMounted);
return () => {
isMounted.current = false;
};
}, [registryId, decodedRepositoryName, filter]);
const handleRefresh = async () => {
toastInfo("Refreshing artifacts...", {
title: "Artifacts Refresh",
durationMs: 1800,
mergeKey: "artifacts-refresh",
});
globalCache.clearAll();
const refreshed = await loadArtifacts({ current: true }, true, true);
if (refreshed) {
toastSuccess(SuccessMessages.DATA_REFRESHED);
}
};
const handleBack = () => {
if (registryId) {
navigate(`/artifact/registries/${registryId}`);
} else {
navigate("/artifact/registries");
}
};
const filterOptions: Array<{ value: ListArtifactsFilter | undefined; label: string }> = [
{ value: undefined, label: "All Types" },
{ value: "chart", label: "Helm Charts" },
{ value: "image", label: "Container Images" },
{ value: "other", label: "Other" },
];
return (
<div className="p-6">
{/* Header */}
<PageHeader
title={`Artifacts - ${decodedRepositoryName}`}
description={`Browse artifacts in ${registryName || registryId || "registry"}`}
icon={Package}
iconColor="text-purple-400"
actions={
<>
<Button
variant="secondary"
icon={ArrowLeft}
onClick={handleBack}
>
Back
</Button>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
</>
}
/>
{/* Filter Bar */}
{!loading && !error && (
<div className="mt-4 flex items-center gap-3">
<Filter className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Filter by type:</span>
<div className="flex gap-2">
{filterOptions.map((option) => (
<button
key={option.value || "all"}
onClick={() => setFilter(option.value)}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
filter === option.value
? "bg-purple-600 text-white"
: "bg-dark-lighter text-gray-300 hover:bg-dark-border"
}`}
>
{option.label}
</button>
))}
</div>
{filter && (
<Badge variant="info">
{artifacts.length} {filter} artifact{artifacts.length !== 1 ? "s" : ""}
</Badge>
)}
</div>
)}
{/* Loading State */}
{loading && <LoadingState message="Loading artifacts..." />}
{/* Error State */}
{error && !loading && (
<ErrorState message={error} onRetry={handleRefresh} />
)}
{/* Empty State */}
{!loading && !error && artifacts.length === 0 && (
<EmptyState
icon={Package}
title="No Artifacts Found"
description={
filter
? `No ${filter} artifacts found in this repository`
: "This repository doesn't contain any artifacts yet"
}
action={{
label: "Back to Repositories",
icon: ArrowLeft,
onClick: handleBack,
}}
/>
)}
{/* Artifacts Grid */}
{!loading && !error && artifacts.length > 0 && (
<div className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{artifacts.map((artifact, index) => (
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={registryId || ""}
tag={artifact}
/>
))}
</div>
</div>
)}
{/* Breadcrumb Info */}
{!loading && artifacts.length > 0 && (
<div className="mt-6 p-4 bg-dark-card border border-dark-border rounded-lg">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>Registry:</span>
<span className="text-white font-medium">{registryName || registryId}</span>
<span className="mx-2">/</span>
<span>Repository:</span>
<span className="text-white font-medium">{decodedRepositoryName}</span>
<span className="mx-2">/</span>
<span>Artifacts:</span>
<span className="text-white font-medium">{artifacts.length}</span>
</div>
</div>
)}
</div>
);
};
export default ArtifactsBrowserPage;

View File

@ -1,240 +0,0 @@
/**
* Registries Browser Page
* Display all registries and their repositories
*/
import React, { useState, useEffect } from "react";
import { Database, RefreshCw, Plus, Package } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/shared";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
EmptyState
} from "@/shared/components";
import { RegistryTreeExplorer } from "../components/RegistryTreeExplorer";
import { listRegistries } from "@/api";
import type { AppRegistry } from "@/core/types";
import { globalCache } from "@/shared/services/artifact-cache";
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
const RegistriesBrowserPage: React.FC = () => {
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
const navigate = useNavigate();
const [registries, setRegistries] = useState<AppRegistry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load registries with global cache
const loadRegistries = async (
isMounted = { current: true },
skipCache = false,
isRefresh = false
) => {
let succeeded = false;
if (!skipCache) {
// Check global cache first
const cachedRegistries = globalCache.get<AppRegistry[]>('registries');
if (cachedRegistries) {
console.log('[RegistriesBrowserPage] Using cached registries');
setRegistries(cachedRegistries);
setLoading(false);
return true;
}
}
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await listRegistries();
if (isMounted.current) {
setRegistries(data);
// Cache registries list (30 minutes TTL)
globalCache.set('registries', data);
succeeded = true;
}
} catch (err) {
if (isMounted.current) {
const errorMsg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
setError(errorMsg);
toastError(errorMsg);
console.error(err);
}
} finally {
if (isMounted.current) {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadRegistries(isMounted);
// Listen for storage events to auto-refresh when registries are added
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'registry_updated') {
console.log('[RegistriesBrowserPage] Detected registry update, refreshing...');
loadRegistries(isMounted);
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
isMounted.current = false;
window.removeEventListener('storage', handleStorageChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh handler
const handleRefresh = async () => {
toastInfo("Refreshing registry list and clearing cache...", {
title: "Registry Browser Refresh",
durationMs: 1800,
mergeKey: "registry-browser-refresh",
});
// Clear all caches to force reload all data
globalCache.clearAll();
const refreshed = await loadRegistries({ current: true }, true, true); // skipCache = true, refresh
if (refreshed) {
toastSuccess(SuccessMessages.DATA_REFRESHED);
}
};
// Navigate to config page
const handleAddRegistry = () => {
navigate("/configuration/registries");
};
return (
<div className="p-6">
{/* Header */}
<PageHeader
title="Artifact - Registries"
description="Browse all OCI registries and their artifacts"
icon={Database}
iconColor="text-purple-400"
actions={
<>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
<Button
variant="primary"
icon={Plus}
onClick={handleAddRegistry}
>
Add Registry
</Button>
</>
}
/>
{/* Loading State */}
{loading && <LoadingState message="Loading registries..." />}
{/* Error State */}
{error && !loading && (
<ErrorState message={error} onRetry={handleRefresh} />
)}
{/* Empty State */}
{!loading && !error && registries.length === 0 && (
<EmptyState
icon={Database}
title="No Registries Found"
description="Add your first OCI registry to start managing artifacts"
action={{
label: "Add Registry",
icon: Plus,
onClick: handleAddRegistry,
}}
/>
)}
{/* Registry Tree Explorer (Three-Level Layout) */}
{!loading && !error && registries.length > 0 && (
<RegistryTreeExplorer registries={registries} />
)}
{/* Enhanced Usage Tips */}
{!loading && registries.length > 0 && (
<div className="mt-6 relative overflow-hidden bg-gradient-to-br from-purple-900/30 via-blue-900/20 to-purple-900/30 border border-purple-500/30 rounded-xl shadow-lg shadow-purple-500/10">
{/* Decorative background */}
<div className="absolute top-0 right-0 w-48 h-48 bg-gradient-to-br from-purple-500/10 to-transparent rounded-full blur-2xl"></div>
<div className="relative p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-lg border border-purple-500/30">
<Package className="w-5 h-5 text-purple-400" />
</div>
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-blue-300">
Quick Tips & Guide
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-purple-500/30 transition-colors">
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-purple-500/20 text-purple-400 rounded-full text-xs font-bold border border-purple-500/30">1</div>
<div className="flex-1">
<p className="text-sm text-slate-300 leading-relaxed">
Click on a registry to <span className="text-purple-400 font-semibold">expand and view</span> its repositories
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-blue-500/30 transition-colors">
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-blue-500/20 text-blue-400 rounded-full text-xs font-bold border border-blue-500/30">2</div>
<div className="flex-1">
<p className="text-sm text-slate-300 leading-relaxed">
Use <span className="text-blue-400 font-semibold">"Browse"</span> to explore all tags and artifacts
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-green-500/30 transition-colors">
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-green-500/20 text-green-400 rounded-full text-xs font-bold border border-green-500/30">3</div>
<div className="flex-1">
<p className="text-sm text-slate-300 leading-relaxed">
Click <span className="text-green-400 font-semibold">"Launch"</span> to deploy artifacts to your cluster
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-slate-800/40 border border-slate-700/50 rounded-lg hover:border-cyan-500/30 transition-colors">
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center bg-cyan-500/20 text-cyan-400 rounded-full text-xs font-bold border border-cyan-500/30">4</div>
<div className="flex-1">
<p className="text-sm text-slate-300 leading-relaxed">
Use <span className="text-cyan-400 font-semibold">search</span> to quickly find specific registries
</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default RegistriesBrowserPage;

View File

@ -1,290 +0,0 @@
/**
* Repositories Browser Page
* 浏览特定 Registry 的所有 Repositories
*/
import React, { useState, useEffect } from "react";
import { Database, RefreshCw, ArrowLeft, Search, Package } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useToast } from "@/shared";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
EmptyState
} from "@/shared/components";
import { listRegistries, listRepositories } from "@/api";
import type { ListRepositories200Item, RepositoryListResponse } from "@/api";
import { RepositoryItem } from "../components/RepositoryItem";
import { globalCache } from "@/shared/services/artifact-cache";
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
const RepositoriesBrowserPage: React.FC = () => {
const { registryId } = useParams<{ registryId: string }>();
const navigate = useNavigate();
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
const [repositories, setRepositories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [registryName, setRegistryName] = useState<string>("");
const [registryUrl, setRegistryUrl] = useState<string>("");
// Load registry info
useEffect(() => {
const loadRegistryInfo = async () => {
if (!registryId) return;
try {
const registries = await listRegistries();
const foundRegistry = registries.find(r => r.id === registryId);
if (foundRegistry) {
setRegistryName(foundRegistry.name || "");
setRegistryUrl(foundRegistry.url || "");
}
} catch (err) {
console.error("Failed to load registry info:", err);
}
};
loadRegistryInfo();
}, [registryId]);
// Load repositories
const loadRepositories = async (
isMounted = { current: true },
skipCache = false,
isRefresh = false
) => {
let succeeded = false;
if (!registryId) {
setError("Registry ID is missing");
setLoading(false);
return false;
}
if (!skipCache && registryId) {
const cached = globalCache.get<string[]>("repositories", registryId);
if (cached) {
console.log(`[RepositoriesBrowserPage] Using cached repositories for ${registryId}`);
setRepositories(cached);
setLoading(false);
return true;
}
}
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const response = await listRepositories({ registryId });
const repos = normalizeRepositoryNames(response);
if (isMounted.current) {
setRepositories(repos);
if (registryId) {
globalCache.set("repositories", repos, registryId);
}
succeeded = true;
}
} catch (err) {
if (isMounted.current) {
const errorMsg = formatApiError(err) || RegistryErrors.LOAD_FAILED;
setError(errorMsg);
toastError(errorMsg);
console.error(err);
}
} finally {
if (isMounted.current) {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadRepositories(isMounted);
return () => {
isMounted.current = false;
};
}, [registryId]);
const handleRefresh = async () => {
toastInfo("Refreshing repositories...", {
title: "Repositories Refresh",
durationMs: 1800,
mergeKey: "repositories-refresh",
});
globalCache.clearAll();
const refreshed = await loadRepositories({ current: true }, true, true);
if (refreshed) {
toastSuccess(SuccessMessages.DATA_REFRESHED);
}
};
const handleBack = () => {
navigate("/artifact/registries");
};
const handleRepositoryClick = (repositoryName: string) => {
if (registryId) {
const encodedName = encodeURIComponent(repositoryName);
navigate(`/artifact/registries/${registryId}/repositories/${encodedName}`);
}
};
// Filter repositories by search term
const filteredRepositories = repositories.filter((repo) =>
repo.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6">
{/* Header */}
<PageHeader
title={`Repositories - ${registryName || registryId || "Registry"}`}
description={`Browse repositories in ${registryName || registryId || "this registry"}`}
icon={Database}
iconColor="text-purple-400"
actions={
<>
<Button
variant="secondary"
icon={ArrowLeft}
onClick={handleBack}
>
Back
</Button>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
</>
}
/>
{/* Search Bar */}
{!loading && !error && repositories.length > 0 && (
<div className="mt-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-dark-lighter border border-dark-border rounded-lg
text-white placeholder-gray-500 focus:outline-none focus:border-purple-500
focus:ring-1 focus:ring-purple-500"
/>
</div>
</div>
)}
{/* Loading State */}
{loading && <LoadingState message="Loading repositories..." />}
{/* Error State */}
{error && !loading && (
<ErrorState message={error} onRetry={handleRefresh} />
)}
{/* Empty State */}
{!loading && !error && filteredRepositories.length === 0 && (
<EmptyState
icon={Database}
title={searchTerm ? "No Repositories Found" : "No Repositories"}
description={
searchTerm
? `No repositories match "${searchTerm}"`
: "This registry doesn't contain any repositories yet"
}
action={
!searchTerm
? {
label: "Back to Registries",
icon: ArrowLeft,
onClick: handleBack,
}
: undefined
}
/>
)}
{/* Repositories List */}
{!loading && !error && filteredRepositories.length > 0 && (
<div className="mt-6 space-y-3">
{filteredRepositories.map((repo) => (
<div
key={repo}
onClick={() => handleRepositoryClick(repo)}
className="cursor-pointer"
>
<RepositoryItem
registryId={registryId || ""}
registryName={registryName}
registryUrl={registryUrl}
repository={repo}
/>
</div>
))}
</div>
)}
{/* Stats */}
{!loading && repositories.length > 0 && (
<div className="mt-6 p-4 bg-dark-card border border-dark-border rounded-lg">
<div className="flex items-center gap-4 text-sm text-gray-400">
<div className="flex items-center gap-2">
<Database className="w-4 h-4" />
<span>Registry:</span>
<span className="text-white font-medium">{registryName || registryId}</span>
</div>
<div className="flex items-center gap-2">
<Package className="w-4 h-4" />
<span>Repositories:</span>
<span className="text-white font-medium">
{filteredRepositories.length}
{searchTerm && ` of ${repositories.length}`}
</span>
</div>
</div>
</div>
)}
</div>
);
};
export default RepositoriesBrowserPage;
function normalizeRepositoryNames(
payload: RepositoryListResponse | ListRepositories200Item[] | null | undefined
): string[] {
if (!payload) {
return [];
}
if (Array.isArray(payload)) {
return payload
.map((repo) => (typeof repo === "string" ? repo : repo.name || ""))
.filter((name): name is string => Boolean(name));
}
if (Array.isArray(payload.repositories)) {
return payload.repositories.filter((name): name is string => typeof name === "string" && Boolean(name));
}
return [];
}

View File

@ -1,40 +0,0 @@
import type { ArtifactListItem } from "@/api";
export type ArtifactCategory = "chart" | "image" | "other";
/**
* 推断 artifact 分类
*
* 后端 OpenAPI 已经返回规范化的类型值:'chart', 'image', 'other'
* 这个函数主要是类型转换和空值处理
*/
export const inferArtifactCategory = (
tag: ArtifactListItem | undefined,
): ArtifactCategory => {
if (!tag) {
return "other";
}
if (!tag.type) {
return "other";
}
// 后端已经返回规范化的值:'chart', 'image', 'other'
return tag.type as ArtifactCategory;
};
export const inferArtifactCategoryFromValues = (
rawType?: string | null,
): ArtifactCategory => {
if (!rawType) return "other";
const typeValue = rawType.toLowerCase().trim();
// 后端返回规范化的值
if (typeValue === "chart" || typeValue === "image" || typeValue === "other") {
return typeValue as ArtifactCategory;
}
return "other";
};

View File

@ -1,11 +0,0 @@
/**
* Authentication Feature
* 用户认证功能 - 登录和注册
*/
// Export pages
export { default as AuthPage } from './pages/AuthPage';
// Export types (if any)
// export * from './types';

View File

@ -1,265 +0,0 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { LogIn, UserPlus, Loader2, Eye, EyeOff } from "lucide-react";
import { useToast } from "@/shared";
import { getErrorMessage } from "@/shared/utils/handleApiError";
import { login as apiLogin, register as apiRegister, type AuthResponse } from "@/api";
type Props = {
onLogin: (response: AuthResponse) => void;
};
const AuthPage: React.FC<Props> = ({ onLogin }) => {
const navigate = useNavigate();
const { success: toastSuccess, error: toastError, info: toastInfo } = useToast();
// Tab state
const [activeTab, setActiveTab] = useState<"login" | "register">("login");
// Login form
const [loginUsername, setLoginUsername] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Register form
const [regUsername, setRegUsername] = useState("");
const [regPassword, setRegPassword] = useState("");
const [regConfirmPwd, setRegConfirmPwd] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [regLoading, setRegLoading] = useState(false);
const [regError, setRegError] = useState<string | null>(null);
// Handle login
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!loginUsername || !loginPassword) return;
setLoginLoading(true);
setLoginError(null);
toastInfo("Logging in...", { title: "Login", durationMs: 1200 });
try {
const response = await apiLogin({ username: loginUsername, password: loginPassword });
// JWT 格式: { access_token, refresh_token, username, ... }
toastSuccess(`Welcome, ${response.username}!`);
onLogin(response);
navigate("/home", { replace: true });
} catch (err: unknown) {
const raw = err as any;
const msg = raw?.message?.includes("Failed to fetch")
? "Network or CORS error: Please check backend CORS or use Vite proxy in development."
: getErrorMessage(err, "Login failed. Please try again later.");
setLoginError(msg);
toastError(msg);
} finally {
setLoginLoading(false);
}
};
// Handle register
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (regPassword !== regConfirmPwd) {
const msg = "Passwords do not match";
setRegError(msg);
toastError(msg);
return;
}
setRegLoading(true);
setRegError(null);
toastInfo("Registering...", { title: "Register", durationMs: 1200 });
try {
const registerResponse = await apiRegister({ username: regUsername, password: regPassword });
toastSuccess(`Welcome, ${registerResponse.username}! Registration successful.`);
toastInfo("Signing you in...", { title: "Auto Login", durationMs: 1200 });
try {
const loginResponse = await apiLogin({ username: regUsername, password: regPassword });
// JWT 格式: { access_token, refresh_token, username, ... }
onLogin(loginResponse);
navigate("/home", { replace: true });
} catch (autoLoginErr: unknown) {
const msg = getErrorMessage(autoLoginErr, "Registration succeeded but auto login failed. Please login manually.");
setRegError(msg);
toastError(msg);
return;
}
} catch (err: unknown) {
const raw = err as any;
let msg = getErrorMessage(err, "Registration failed. Please try again later.");
// 提供更友好的错误提示
if (raw?.message?.includes("Failed to fetch")) {
msg = "Network or CORS error: Please check backend CORS or use Vite proxy in development.";
} else if (raw?.message?.includes("username")) {
msg = "Username is already taken or invalid.";
}
setRegError(msg);
toastError(msg);
console.error("Registration error:", err);
} finally {
setRegLoading(false);
}
};
return (
<div className="relative min-h-screen bg-dark text-primary flex items-center justify-center px-4 sm:px-6">
<div className="pointer-events-none absolute inset-0 bg-app-gradient opacity-90" aria-hidden="true" />
<div className="relative w-full max-w-md p-6 sm:p-7 bg-dark-lighter/80 border border-dark-border/70 rounded-2xl shadow-soft backdrop-blur-xl">
{/* Tab Header */}
<div className="flex border-b border-dark-border/60 mb-6">
<button
className={`flex-1 py-3 text-center font-semibold transition-colors ${
activeTab === "login"
? "text-brand-accent border-b-2 border-brand-accent"
: "text-secondary hover:text-primary"
}`}
onClick={() => setActiveTab("login")}
>
<LogIn className="w-5 h-5 inline-block mr-2" />
Login
</button>
<button
className={`flex-1 py-3 text-center font-semibold transition-colors ${
activeTab === "register"
? "text-accent-teal border-b-2 border-accent-teal"
: "text-secondary hover:text-primary"
}`}
onClick={() => setActiveTab("register")}
>
<UserPlus className="w-5 h-5 inline-block mr-2" />
Register
</button>
</div>
{/* Login Form */}
{activeTab === "login" && (
<div className="animate-fadeIn">
<header className="mb-6 text-center">
<LogIn className="w-10 h-10 text-brand-accent mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-primary">Welcome</h1>
<p className="text-secondary text-sm mt-1">Access your manager</p>
</header>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm text-secondary">Username</label>
<input
value={loginUsername}
onChange={(e) => setLoginUsername(e.target.value)}
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-brand-accent focus:border-brand-accent focus:outline-none transition-shadow"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm text-secondary">Password</label>
<input
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-brand-accent focus:border-brand-accent focus:outline-none transition-shadow"
autoComplete="current-password"
required
/>
</div>
<button
type="submit"
disabled={loginLoading}
className={`w-full disabled:opacity-60 font-semibold py-2.5 rounded-lg flex items-center justify-center gap-2 transition-colors duration-150
${loginLoading ? "bg-brand-accent/70 cursor-wait text-primary" : "bg-brand-accent text-dark hover:bg-brand-accent/90"}`}
>
{loginLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogIn className="w-4 h-4" />}
{loginLoading ? "Logging in..." : "Login"}
</button>
{loginError && <p className="text-red-400 text-center text-sm">{loginError}</p>}
</form>
</div>
)}
{/* Register Form */}
{activeTab === "register" && (
<div className="animate-fadeIn">
<header className="mb-6 text-center">
<UserPlus className="w-10 h-10 text-accent-teal mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-primary">Create Account</h1>
<p className="text-secondary text-sm mt-1">Create a new account</p>
</header>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm text-secondary">Username</label>
<input
value={regUsername}
onChange={(e) => setRegUsername(e.target.value)}
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm text-secondary">Password</label>
<div className="relative">
<input
type={showPwd ? "text" : "password"}
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow pr-10"
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowPwd((s) => !s)}
className="absolute inset-y-0 right-2 flex items-center text-secondary hover:text-primary transition-colors"
aria-label={showPwd ? "Hide password" : "Show password"}
>
{showPwd ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm text-secondary">Confirm Password</label>
<input
type={showPwd ? "text" : "password"}
value={regConfirmPwd}
onChange={(e) => setRegConfirmPwd(e.target.value)}
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow"
autoComplete="new-password"
required
/>
</div>
<button
type="submit"
disabled={regLoading}
className={`w-full disabled:opacity-60 font-semibold py-2.5 rounded-lg flex items-center justify-center gap-2 transition-colors duration-150
${regLoading ? "bg-accent-teal/70 cursor-wait text-primary" : "bg-accent-teal text-dark hover:bg-accent-teal/90"}`}
>
{regLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
{regLoading ? "Registering..." : "Register"}
</button>
{regError && <p className="text-red-400 text-center text-sm">{regError}</p>}
</form>
</div>
)}
</div>
</div>
);
};
export default AuthPage;

View File

@ -1,362 +0,0 @@
/**
* Cluster Configuration Form Component
* For adding and editing cluster configurations
*/
import React, { useState, useEffect } from "react";
import { Server, Key, FileText } from "lucide-react";
import type { ClusterConfig } from "@/core/types";
import { ValidationErrors } from "@/shared/utils";
import { ButtonText, LabelText } from "@/shared/constants";
interface ClusterFormProps {
cluster?: ClusterConfig;
onSave: (data: Omit<ClusterConfig, "id" | "createdAt" | "updatedAt">) => void;
onCancel: () => void;
}
export const ClusterForm: React.FC<ClusterFormProps> = ({
cluster,
onSave,
onCancel,
}) => {
const [formData, setFormData] = useState({
name: cluster?.name ?? "",
host: cluster?.host ?? "",
caData: cluster?.caData ?? "",
certData: cluster?.certData ?? "",
keyData: cluster?.keyData ?? "",
token: cluster?.token ?? "",
description: cluster?.description ?? "",
});
// 新证书输入(编辑模式)
const [newCaData, setNewCaData] = useState<string>("");
const [newCertData, setNewCertData] = useState<string>("");
const [newKeyData, setNewKeyData] = useState<string>("");
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (cluster) {
setFormData({
name: cluster.name ?? "",
host: cluster.host ?? "",
caData: cluster.caData ?? "",
certData: cluster.certData ?? "",
keyData: cluster.keyData ?? "",
token: cluster.token ?? "",
description: cluster.description ?? "",
});
}
}, [cluster]);
const handleChange = (
field: keyof typeof formData,
value: string
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear field error
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = ValidationErrors.REQUIRED_FIELD("Cluster name");
}
if (!formData.host.trim()) {
newErrors.host = ValidationErrors.REQUIRED_FIELD("API Server URL");
} else if (!/^https?:\/\/.+/.test(formData.host.trim())) {
newErrors.host = ValidationErrors.INVALID_URL;
}
// 创建模式:必填证书
if (!cluster) {
if (!formData.caData.trim()) {
newErrors.caData = ValidationErrors.REQUIRED_FIELD("CA Certificate");
}
if (!formData.certData.trim()) {
newErrors.certData = ValidationErrors.REQUIRED_FIELD("Client Certificate");
}
if (!formData.keyData.trim()) {
newErrors.keyData = ValidationErrors.REQUIRED_FIELD("Client Key");
}
}
// 编辑模式:证书可选(留空保持不变)
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
// 准备提交数据 - 使用 OpenAPI 生成的 camelCase 字段名
const submitData: Record<string, string> = {
name: formData.name.trim(),
host: formData.host.trim(),
};
// 只添加非空的 description
if (formData.description.trim()) {
submitData.description = formData.description.trim();
}
if (cluster) {
// 编辑模式:只发送用户输入的新值(使用 camelCase
if (newCaData.trim()) submitData.caData = newCaData.trim();
if (newCertData.trim()) submitData.certData = newCertData.trim();
if (newKeyData.trim()) submitData.keyData = newKeyData.trim();
} else {
// 创建模式:发送所有必填字段(使用 camelCase
submitData.caData = formData.caData.trim();
submitData.certData = formData.certData.trim();
submitData.keyData = formData.keyData.trim();
if (formData.token?.trim()) submitData.token = formData.token.trim();
}
onSave(submitData);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
{/* Cluster Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
{LabelText.NAME} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
className={`w-full bg-gray-900/60 border ${
errors.name ? "border-red-500" : "border-gray-600"
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
placeholder="e.g., Production Cluster"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-400">{errors.name}</p>
)}
</div>
{/* API Server URL */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
API Server URL <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.host}
onChange={(e) => handleChange("host", e.target.value)}
className={`w-full bg-gray-900/60 border ${
errors.host ? "border-red-500" : "border-gray-600"
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
placeholder="e.g., https://kubernetes.example.com:6443"
/>
{errors.host && (
<p className="mt-1 text-sm text-red-400">{errors.host}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Kubernetes API Server address (usually HTTPS protocol)
</p>
</div>
{/* CA Certificate */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<Key className="w-4 h-4 text-green-400" />
CA Certificate (Base64) {!cluster && <span className="text-red-400">*</span>}
</label>
{cluster ? (
// 编辑模式:显示状态和新输入
<>
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
<span className="text-gray-400 text-sm">:</span>
<span className="text-white font-mono text-xs">{formData.caData}</span>
{cluster.hasCaData && (
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
</span>
)}
</div>
<textarea
value={newCaData}
onChange={(e) => setNewCaData(e.target.value)}
rows={3}
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
placeholder="粘贴新的 CA 证书以覆盖(留空保持不变)"
/>
<p className="mt-1 text-xs text-gray-500">
💡
</p>
</>
) : (
// 创建模式:必填
<>
<textarea
value={formData.caData}
onChange={(e) => handleChange("caData", e.target.value)}
rows={4}
className={`w-full bg-gray-900/60 border ${
errors.caData ? "border-red-500" : "border-gray-600"
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t..."
/>
{errors.caData && (
<p className="mt-1 text-sm text-red-400">{errors.caData}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Cluster CA certificate in base64 format (certificate-authority-data)
</p>
</>
)}
</div>
{/* Client Certificate */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<Key className="w-4 h-4 text-yellow-400" />
Client Certificate (Base64) {!cluster && <span className="text-red-400">*</span>}
</label>
{cluster ? (
// 编辑模式
<>
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
<span className="text-gray-400 text-sm">:</span>
<span className="text-white font-mono text-xs">{formData.certData}</span>
{cluster.hasCertData && (
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
</span>
)}
</div>
<textarea
value={newCertData}
onChange={(e) => setNewCertData(e.target.value)}
rows={3}
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
placeholder="粘贴新的客户端证书以覆盖(留空保持不变)"
/>
<p className="mt-1 text-xs text-gray-500">
💡
</p>
</>
) : (
<>
<textarea
value={formData.certData}
onChange={(e) => handleChange("certData", e.target.value)}
rows={4}
className={`w-full bg-gray-900/60 border ${
errors.certData ? "border-red-500" : "border-gray-600"
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0t..."
/>
{errors.certData && (
<p className="mt-1 text-sm text-red-400">{errors.certData}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Client certificate in base64 format (client-certificate-data)
</p>
</>
)}
</div>
{/* Client Key */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<Key className="w-4 h-4 text-red-400" />
Client Key (Base64) {!cluster && <span className="text-red-400">*</span>}
</label>
{cluster ? (
// 编辑模式
<>
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
<span className="text-gray-400 text-sm">:</span>
<span className="text-white font-mono text-xs">{formData.keyData}</span>
{cluster.hasKeyData && (
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
</span>
)}
</div>
<textarea
value={newKeyData}
onChange={(e) => setNewKeyData(e.target.value)}
rows={3}
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
placeholder="粘贴新的客户端密钥以覆盖(留空保持不变)"
/>
<p className="mt-1 text-xs text-gray-500">
💡
</p>
</>
) : (
<>
<textarea
value={formData.keyData}
onChange={(e) => handleChange("keyData", e.target.value)}
rows={4}
className={`w-full bg-gray-900/60 border ${
errors.keyData ? "border-red-500" : "border-gray-600"
} rounded-lg p-2.5 sm:p-3 text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none`}
placeholder="LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBL..."
/>
{errors.keyData && (
<p className="mt-1 text-sm text-red-400">{errors.keyData}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Client private key in base64 format (client-key-data)
</p>
</>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" />
{LabelText.DESCRIPTION} (Optional)
</label>
<textarea
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
rows={2}
className="w-full bg-gray-900/60 border border-gray-600 rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
placeholder="Cluster description (e.g., purpose, environment)"
/>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-3 sm:pt-4 border-t border-gray-700">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm sm:text-base"
>
{ButtonText.CANCEL}
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm sm:text-base"
>
{cluster ? ButtonText.SAVE : `${ButtonText.ADD} ${LabelText.CLUSTER}`}
</button>
</div>
</form>
);
};

View File

@ -1,135 +0,0 @@
/**
* Cluster Configuration List Component
* Display cluster list with edit and delete actions
*/
import React from "react";
import { Server, Edit, Trash2, Key, ExternalLink } from "lucide-react";
import type { ClusterConfig } from "@/core/types";
import { LoadingText, EmptyText } from "@/shared/constants";
interface ClusterListProps {
clusters: ClusterConfig[];
loading: boolean;
onEdit: (cluster: ClusterConfig) => void;
onDelete: (cluster: ClusterConfig) => void;
}
export const ClusterList: React.FC<ClusterListProps> = ({
clusters,
loading,
onEdit,
onDelete,
}) => {
if (loading) {
return (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400"></div>
<p className="text-gray-400 mt-4">{LoadingText.LOADING_CLUSTERS}</p>
</div>
);
}
if (clusters.length === 0) {
return (
<div className="text-center py-12">
<Server className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 text-lg mb-2">{EmptyText.NO_CLUSTERS}</p>
<p className="text-gray-500 text-sm">{EmptyText.NO_CLUSTERS_DESC}</p>
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{clusters.map((cluster) => (
<div
key={cluster.id}
className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 hover:bg-gray-800 hover:border-gray-600 transition group"
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Server className="w-5 h-5 text-blue-400" />
{cluster.name}
</h3>
{cluster.description && (
<p className="text-sm text-gray-400">{cluster.description}</p>
)}
</div>
<div className="flex gap-2 ml-4">
<button
onClick={() => onEdit(cluster)}
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(cluster)}
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Server Info */}
<div className="space-y-3">
<div className="flex items-start gap-2">
<ExternalLink className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 mb-0.5">API Server URL</p>
<p className="text-sm text-gray-300 font-mono truncate" title={cluster.host}>
{cluster.host}
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex items-start gap-2">
<Key className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 mb-0.5">CA Certificate</p>
<p className={`text-xs ${cluster.hasCaData ? "text-green-400" : "text-gray-500"}`}>
{cluster.hasCaData ? "✓ Configured" : "✗ Not Configured"}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Key className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 mb-0.5">Client Cert</p>
<p className={`text-xs ${cluster.hasCertData ? "text-yellow-400" : "text-gray-500"}`}>
{cluster.hasCertData ? "✓ Configured" : "✗ Not Configured"}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Key className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 mb-0.5">Client Key</p>
<p className={`text-xs ${cluster.hasKeyData ? "text-red-400" : "text-gray-500"}`}>
{cluster.hasKeyData ? "✓ Configured" : "✗ Not Configured"}
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
{cluster.createdAt && (
<div className="mt-4 pt-4 border-t border-gray-700/50">
<p className="text-xs text-gray-500">
Created: {new Date(cluster.createdAt).toLocaleString("en-US")}
</p>
</div>
)}
</div>
))}
</div>
);
};

View File

@ -1,12 +0,0 @@
/**
* Cluster Management Feature
* 集群配置管理功能
*/
// Export pages
export { default as ClusterConfigPage } from './pages/ClusterConfigPage';
// Export components
export { ClusterForm } from './components/ClusterForm';
export { ClusterList } from './components/ClusterList';

View File

@ -1,247 +0,0 @@
/**
* Cluster Configuration Page
* Manage Kubernetes cluster configurations
*/
import React, { useState, useEffect } from "react";
import { Plus, RefreshCw, Server } from "lucide-react";
import { useToast, Modal, Button, PageHeader } from "@/shared";
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
import { ClusterForm } from "../components/ClusterForm";
import { ClusterList } from "../components/ClusterList";
import {
listClusters,
createCluster,
updateCluster,
deleteCluster,
} from "@/api";
import type { ClusterConfig, ClusterResponse } from "@/core/types";
const ClusterConfigPage: React.FC = () => {
const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterConfig[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingCluster, setEditingCluster] = useState<ClusterConfig | undefined>(undefined);
// Load clusters
const loadClusters = async (isMounted = { current: true }, isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
let succeeded = false;
try {
const data = await listClusters();
// Only update state if component is still mounted
if (isMounted.current) {
setClusters(data);
succeeded = true;
}
} catch (err) {
if (isMounted.current) {
toastError(formatApiError(err) || ClusterErrors.LOAD_FAILED);
console.error(err);
}
} finally {
if (isMounted.current) {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadClusters(isMounted);
// Cleanup: mark component as unmounted
return () => {
isMounted.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh clusters
const handleRefresh = async () => {
toastInfo("Refreshing clusters...", {
title: "Cluster Refresh",
durationMs: 1800,
mergeKey: "clusters-refresh",
});
const refreshed = await loadClusters({ current: true }, true);
if (refreshed) {
success(SuccessMessages.DATA_REFRESHED);
}
};
// Add cluster
const handleAdd = () => {
setEditingCluster(undefined);
setShowModal(true);
};
// Edit cluster
const handleEdit = (cluster: ClusterResponse) => {
setEditingCluster(cluster);
setShowModal(true);
};
// Save cluster
const handleSave = async (
data: Omit<ClusterResponse, "id" | "createdAt" | "updatedAt">
) => {
try {
const actionLabel = editingCluster ? "Updating cluster..." : "Creating cluster...";
toastInfo(actionLabel, {
title: editingCluster ? "Update Cluster" : "Create Cluster",
durationMs: 1800,
mergeKey: editingCluster ? `cluster-save-${editingCluster.id}` : "cluster-create",
});
if (editingCluster) {
if (!editingCluster.id) {
toastError("Cluster identifier is missing. Please refresh and try again.");
return;
}
await updateCluster({ clusterId: editingCluster.id }, data);
success(SuccessMessages.CLUSTER_UPDATED);
} else {
// Build create data with only non-empty auth fields
const createData: any = {
name: data.name,
host: data.host,
description: data.description,
};
// Add certificate auth if all three fields are provided
if (data.caData && data.certData && data.keyData) {
createData.caData = data.caData;
createData.certData = data.certData;
createData.keyData = data.keyData;
}
// Add token auth if provided
if (data.token) {
createData.token = data.token;
}
await createCluster(createData);
success(SuccessMessages.CLUSTER_CREATED);
}
setShowModal(false);
setEditingCluster(undefined);
loadClusters({ current: true });
} catch (error) {
const errorMsg = editingCluster ? ClusterErrors.UPDATE_FAILED : ClusterErrors.CREATE_FAILED;
toastError(formatApiError(error) || errorMsg);
console.error(error);
}
};
// Delete cluster
const handleDelete = async (cluster: ClusterResponse) => {
if (!confirm(`Are you sure you want to delete cluster "${cluster.name}"? This action cannot be undone.`)) {
return;
}
try {
toastInfo(`Deleting cluster "${cluster.name}"...`, {
title: "Delete Cluster",
durationMs: 1800,
mergeKey: `cluster-delete-${cluster.id}`,
});
if (!cluster.id) {
toastError("Cluster identifier is missing. Please refresh and try again.");
return;
}
await deleteCluster({ clusterId: cluster.id });
success(SuccessMessages.CLUSTER_DELETED);
loadClusters({ current: true });
} catch (error) {
toastError(formatApiError(error) || ClusterErrors.DELETE_FAILED);
console.error(error);
}
};
return (
<div className="p-6">
{/* Header */}
<PageHeader
title="Configuration - Clusters"
description="Manage Kubernetes cluster connections and authentication"
icon={Server}
iconColor="text-blue-400"
actions={
<>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
<Button
variant="primary"
icon={Plus}
onClick={handleAdd}
className="bg-blue-600 hover:bg-blue-700 border-blue-600"
>
Add Cluster
</Button>
</>
}
/>
{/* Cluster List */}
<ClusterList
clusters={clusters}
loading={loading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* Info Section */}
<div className="mt-8 p-4 bg-blue-900/20 border border-blue-700/50 rounded-lg">
<h3 className="text-sm font-semibold text-blue-300 mb-2">💡 Usage Tips</h3>
<ul className="text-sm text-gray-400 space-y-1">
<li> Cluster configuration contains all information needed to connect to a Kubernetes cluster</li>
<li> <strong>Cluster Address</strong>: URL of the Kubernetes API Server</li>
<li> <strong>Certificate Format</strong>: All certificate fields use base64 encoded strings (same as kubeconfig)</li>
<li> <strong>Backend Processing</strong>: The backend automatically handles certificate format conversion</li>
<li> You can select clusters for application deployment on the instances page</li>
<li> Recommended to configure separate clusters for different environments (development, testing, production)</li>
</ul>
</div>
{/* Add/Edit Modal */}
<Modal
open={showModal}
onClose={() => {
setShowModal(false);
setEditingCluster(undefined);
}}
title={editingCluster ? "Edit Cluster Configuration" : "Add Cluster Configuration"}
>
<ClusterForm
cluster={editingCluster}
onSave={handleSave}
onCancel={() => {
setShowModal(false);
setEditingCluster(undefined);
}}
/>
</Modal>
</div>
);
};
export default ClusterConfigPage;

View File

@ -1,15 +0,0 @@
/**
* Configuration Module
* 配置模块 - 集群和仓库配置
*/
// Clusters
export { default as ClusterConfigPage } from './clusters/pages/ClusterConfigPage';
export * from './clusters/components/ClusterList';
export * from './clusters/components/ClusterForm';
// Registries
export { default as RegistryConfigPage } from './registries/pages/RegistryConfigPage';
export * from './registries/components/RegistryList';
export * from './registries/components/RegistryForm';

View File

@ -1,260 +0,0 @@
/**
* Registry Configuration Form
* For adding and editing registry configurations
*/
import React, { useState } from "react";
import { Save, X, TestTube } from "lucide-react";
import type { RegistryResponse } from "@/core/types";
import { checkRegistryHealth } from "@/api";
import { useToast } from "@/shared";
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
import { ButtonText, LabelText } from "@/shared/constants";
interface RegistryFormProps {
registry?: RegistryResponse;
onSave: (data: Omit<RegistryResponse, "id">) => void;
onCancel: () => void;
}
export const RegistryForm: React.FC<RegistryFormProps> = ({
registry,
onSave,
onCancel,
}) => {
const { success, error: toastError, info: toastInfo } = useToast();
const [testing, setTesting] = useState(false);
const [formData, setFormData] = useState<
Omit<RegistryResponse, "id" | "createdAt" | "updatedAt">
>({
name: registry?.name || "",
url: registry?.url || "",
description: registry?.description || "",
username: registry?.username || "",
password: registry?.password || "",
hasPassword: registry?.hasPassword || false,
insecure: registry?.insecure || false,
});
// 新密码输入(编辑模式)
const [newPassword, setNewPassword] = useState<string>("");
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value, type } = e.target;
const checked = type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 准备提交数据
const submitData = { ...formData };
// 编辑模式:如果用户输入了新密码,使用新密码;否则不发送密码字段
if (registry) {
if (newPassword) {
submitData.password = newPassword;
} else {
// 不发送密码字段,保持后端原有密码
delete (submitData as any).password;
}
}
onSave(submitData);
};
const handleTest = async () => {
if (!registry?.id) {
toastError("Please save the registry configuration before testing the connection.");
return;
}
toastInfo("Testing registry connection...", {
title: "Connection Test",
durationMs: 1800,
mergeKey: `registry-test-${registry.id}`,
});
setTesting(true);
try {
const result = await checkRegistryHealth({ registryId: registry.id }) as any;
// ✅ FIX: Backend returns { healthy: boolean, message: string }
if (result.healthy === true) {
success(result.message || SuccessMessages.REGISTRY_CONNECTION_OK);
} else {
const errorMsg = result.message || RegistryErrors.CONNECTION_TEST_FAILED;
toastError(errorMsg);
}
} catch (error) {
toastError(formatApiError(error) || RegistryErrors.CONNECTION_TEST_FAILED);
console.error(error);
} finally {
setTesting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{LabelText.NAME} <span className="text-red-400">*</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="e.g., Harbor Production"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Registry URL <span className="text-red-400">*</span>
</label>
<input
type="url"
name="url"
value={formData.url}
onChange={handleChange}
required
placeholder="https://registry.example.com"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
OCI registry access URL (e.g., https://registry.hub.docker.com)
</p>
</div>
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{LabelText.USERNAME} <span className="text-red-400">*</span>
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
required
placeholder="Registry username"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{LabelText.PASSWORD} {!registry && <span className="text-red-400">*</span>}
</label>
{registry ? (
// 编辑模式:显示状态和新密码输入
<>
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg">
<span className="text-gray-400 text-sm">:</span>
<span className="text-white font-mono">{formData.password}</span>
{formData.hasPassword && (
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full"></span>
</span>
)}
</div>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="输入新密码以覆盖(留空保持不变)"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
💡
</p>
</>
) : (
// 创建模式:必填
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
placeholder="Registry password or access token"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{LabelText.DESCRIPTION}
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
placeholder="Registry purpose or description"
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:outline-none resize-none"
/>
</div>
{/* Insecure */}
<div className="flex items-center">
<input
type="checkbox"
id="insecure"
name="insecure"
checked={formData.insecure}
onChange={handleChange}
className="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
/>
<label htmlFor="insecure" className="ml-2 text-sm text-gray-300">
Allow insecure connection (skip SSL certificate verification)
</label>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-4">
<button
type="submit"
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
{ButtonText.SAVE}
</button>
{registry?.id && (
<button
type="button"
onClick={handleTest}
disabled={testing}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition flex items-center gap-2 disabled:opacity-50"
>
<TestTube className={`w-4 h-4 ${testing ? "animate-pulse" : ""}`} />
{ButtonText.TEST_CONNECTION}
</button>
)}
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition flex items-center gap-2"
>
<X className="w-4 h-4" />
{ButtonText.CANCEL}
</button>
</div>
</form>
);
};

View File

@ -1,111 +0,0 @@
/**
* Registry Configuration List
* Display all registry configurations
*/
import React from "react";
import { Edit2, Trash2, Database, ExternalLink } from "lucide-react";
import type { AppRegistry } from "@/core/types";
import { EmptyStateSimple } from "@/shared/components";
import { LoadingText, EmptyText } from "@/shared/constants";
interface RegistryListProps {
registries: AppRegistry[];
loading: boolean;
onEdit: (registry: AppRegistry) => void;
onDelete: (registry: AppRegistry) => void;
}
export const RegistryList: React.FC<RegistryListProps> = ({
registries,
loading,
onEdit,
onDelete,
}) => {
if (loading) {
return (
<div className="text-center py-12 text-gray-400">
{LoadingText.LOADING_REGISTRIES}
</div>
);
}
if (registries.length === 0) {
return (
<EmptyStateSimple
title={EmptyText.NO_REGISTRIES}
description={EmptyText.NO_REGISTRIES_DESC}
Icon={Database}
/>
);
}
return (
<div className="space-y-4">
{registries.map((registry) => (
<div
key={registry.id}
className="p-4 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 transition"
>
<div className="flex items-start justify-between">
{/* Left: Basic Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Database className="w-5 h-5 text-purple-400 flex-shrink-0" />
<h3 className="text-lg font-semibold text-white">
{registry.name}
</h3>
{registry.insecure && (
<span className="px-2 py-0.5 bg-yellow-900/30 text-yellow-400 text-xs rounded">
Insecure
</span>
)}
</div>
<div className="ml-8 space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-400">
<ExternalLink className="w-4 h-4" />
<a
href={registry.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-purple-400 transition"
>
{registry.url}
</a>
</div>
{registry.description && (
<p className="text-sm text-gray-500">{registry.description}</p>
)}
{registry.username && (
<p className="text-sm text-gray-500">
Username: <span className="text-gray-400">{registry.username}</span>
</p>
)}
</div>
</div>
{/* Right: Action Buttons */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onEdit(registry)}
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-gray-700 rounded-lg transition"
title="Edit"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(registry)}
className="p-2 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded-lg transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
);
};

View File

@ -1,12 +0,0 @@
/**
* Registry Management Feature
* OCI 仓库配置管理功能
*/
// Export pages
export { default as RegistryConfigPage } from './pages/RegistryConfigPage';
// Export components
export { RegistryForm } from './components/RegistryForm';
export { RegistryList } from './components/RegistryList';

View File

@ -1,248 +0,0 @@
/**
* Registry Configuration Page
* Manage OCI Registry configurations
*/
import React, { useState, useEffect } from "react";
import { Plus, RefreshCw, Database } from "lucide-react";
import { useToast } from "@/shared";
import { Modal, PageHeader, Button } from "@/shared/components";
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
import { RegistryForm } from "../components/RegistryForm";
import { RegistryList } from "../components/RegistryList";
import {
listRegistries,
createRegistry,
updateRegistry,
deleteRegistry,
} from "@/api";
import type { CreateRegistryRequest } from "@/api";
import type { AppRegistry, RegistryResponse } from "@/core/types";
const RegistryConfigPage: React.FC = () => {
const { success, error: toastError, info: toastInfo } = useToast();
const [registries, setRegistries] = useState<AppRegistry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingRegistry, setEditingRegistry] = useState<AppRegistry | undefined>(undefined);
// Load registries
const loadRegistries = async (isMounted = { current: true }, isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
let succeeded = false;
try {
const data = await listRegistries();
// Only update state if component is still mounted
if (isMounted.current) {
setRegistries(data);
succeeded = true;
}
} catch (err) {
if (isMounted.current) {
toastError(formatApiError(err) || RegistryErrors.LOAD_FAILED);
console.error(err);
}
} finally {
if (isMounted.current) {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadRegistries(isMounted);
// Cleanup: mark component as unmounted
return () => {
isMounted.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh registries
const handleRefresh = async () => {
toastInfo("Refreshing registries...", {
title: "Registry Refresh",
durationMs: 1800,
mergeKey: "registries-refresh",
});
const refreshed = await loadRegistries({ current: true }, true);
if (refreshed) {
success(SuccessMessages.DATA_REFRESHED);
}
};
// Add registry
const handleAdd = () => {
setEditingRegistry(undefined);
setShowModal(true);
};
// Edit registry
const handleEdit = (registry: RegistryResponse) => {
setEditingRegistry(registry);
setShowModal(true);
};
// Save registry
const handleSave = async (data: Omit<RegistryResponse, "id">) => {
try {
const actionLabel = editingRegistry ? "Updating registry..." : "Creating registry...";
toastInfo(actionLabel, {
title: editingRegistry ? "Update Registry" : "Create Registry",
durationMs: 1800,
mergeKey: editingRegistry ? `registry-save-${editingRegistry.id}` : "registry-create",
});
if (editingRegistry) {
if (!editingRegistry.id) {
toastError("Registry identifier is missing. Please refresh and try again.");
return;
}
await updateRegistry({ registryId: editingRegistry.id }, data);
success(SuccessMessages.REGISTRY_UPDATED);
} else {
if (!data.name || !data.url || !data.username || !data.password) {
toastError("Name, URL, username, and password are required to create a registry.");
return;
}
const createData: CreateRegistryRequest = {
name: data.name,
url: data.url,
username: data.username,
password: data.password,
description: data.description || "",
insecure: data.insecure ?? false,
};
const newRegistry = await createRegistry(createData);
success(SuccessMessages.REGISTRY_CREATED);
console.log("New registry added:", newRegistry);
}
setShowModal(false);
setEditingRegistry(undefined);
await loadRegistries({ current: true });
// Notify other pages to refresh (if registry browser is open)
localStorage.setItem('registry_updated', Date.now().toString());
localStorage.removeItem('registry_updated');
} catch (error) {
const errorMsg = editingRegistry ? RegistryErrors.UPDATE_FAILED : RegistryErrors.CREATE_FAILED;
toastError(formatApiError(error) || errorMsg);
console.error("Registry save error:", error);
// Don't close modal so user can retry
throw error;
}
};
// Delete registry
const handleDelete = async (registry: RegistryResponse) => {
if (!confirm(`Are you sure you want to delete registry "${registry.name}"? This action cannot be undone.`)) {
return;
}
try {
toastInfo(`Deleting registry "${registry.name}"...`, {
title: "Delete Registry",
durationMs: 1800,
mergeKey: `registry-delete-${registry.id}`,
});
if (!registry.id) {
toastError("Registry identifier is missing. Please refresh and try again.");
return;
}
await deleteRegistry({ registryId: registry.id });
success(SuccessMessages.REGISTRY_DELETED);
loadRegistries({ current: true });
} catch (error) {
toastError(formatApiError(error) || RegistryErrors.DELETE_FAILED);
console.error(error);
}
};
return (
<div className="p-6">
{/* Header */}
<PageHeader
title="Configuration - Registries"
description="Manage OCI Registry connections and authentication"
icon={Database}
iconColor="text-purple-400"
actions={
<>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
spinIcon={true}
>
Refresh
</Button>
<Button
variant="primary"
icon={Plus}
onClick={handleAdd}
>
Add Registry
</Button>
</>
}
/>
{/* Registry List */}
<RegistryList
registries={registries}
loading={loading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* Info Section */}
<div className="mt-8 p-4 bg-purple-900/20 border border-purple-700/50 rounded-lg">
<h3 className="text-sm font-semibold text-purple-300 mb-2">💡 Usage Tips</h3>
<ul className="text-sm text-gray-400 space-y-1">
<li> Registry configuration is used to connect to OCI-compliant container registries (Docker Hub, Harbor, GitHub Container Registry, etc.)</li>
<li> <strong>Registry URL</strong>: The access address of the container registry (e.g., https://registry.hub.docker.com)</li>
<li> <strong>Authentication</strong>: Username and password for accessing private registries</li>
<li> <strong>OCI Standard</strong>: Fully compatible with OCI Distribution Specification v2</li>
<li> The system automatically accesses repositories and retrieves image lists via OCI v2 API</li>
</ul>
</div>
{/* Add/Edit Modal */}
<Modal
open={showModal}
onClose={() => {
setShowModal(false);
setEditingRegistry(undefined);
}}
title={editingRegistry ? "Edit Registry Configuration" : "Add Registry Configuration"}
icon={Database}
size="lg"
>
<RegistryForm
registry={editingRegistry}
onSave={handleSave}
onCancel={() => {
setShowModal(false);
setEditingRegistry(undefined);
}}
/>
</Modal>
</div>
);
};
export default RegistryConfigPage;

View File

@ -1,8 +0,0 @@
/**
* Dashboard Feature
* 首页仪表板
*/
// Export pages
export { default as HomePage } from './pages/HomePage';

View File

@ -1,247 +0,0 @@
import React from "react";
import { Boxes, Server, Database, Activity, Package, Rocket, Settings } from "lucide-react";
import { useNavigate } from "react-router-dom";
const HomePage: React.FC = () => {
const navigate = useNavigate();
// Main categories
const categories = [
{
key: "config",
title: "Configuration",
icon: <Settings className="w-8 h-8 text-brand-accent" />,
color: "blue",
description: "System configuration management",
children: [
{
icon: <Server className="w-10 h-10 text-accent-teal" />,
title: "Clusters",
description: "Manage Kubernetes cluster kubeconfig and contexts",
path: "/configuration/clusters",
},
{
icon: <Database className="w-10 h-10 text-brand-light" />,
title: "Registries",
description: "Configure OCI registries and Helm chart repositories",
path: "/configuration/registries",
},
],
},
{
key: "cluster",
title: "Cluster",
icon: <Activity className="w-8 h-8 text-accent-teal" />,
color: "emerald",
description: "Cluster management and monitoring",
children: [
{
icon: <Activity className="w-10 h-10 text-accent-teal" />,
title: "Monitoring",
description: "Real-time cluster status and resource monitoring",
path: "/cluster/monitor",
},
],
},
{
key: "artifact",
title: "Artifact",
icon: <Package className="w-8 h-8 text-brand-light" />,
color: "purple",
description: "Artifact and application lifecycle management",
children: [
{
icon: <Database className="w-10 h-10 text-brand-light" />,
title: "Registries",
description: "Browse and manage OCI artifacts",
path: "/artifact/registries",
},
{
icon: <Rocket className="w-10 h-10 text-brand-accent" />,
title: "Instances",
description: "Deploy and manage applications",
path: "/artifact/instances",
},
],
},
];
return (
<div className="min-h-full px-4 py-6 sm:px-6 lg:px-10">
{/* Hero Section */}
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<div className="flex justify-center mb-6">
<div className="relative p-5 rounded-3xl bg-dark-lighter/80 border border-dark-border/60 shadow-glow">
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-brand-accent/25 via-brand-accent/5 to-transparent blur-xl" />
<Boxes className="relative w-16 h-16 text-brand-accent" />
</div>
</div>
<h1 className="text-4xl sm:text-5xl font-semibold text-primary mb-4 tracking-wide">
Welcome to OCDP Management Platform
</h1>
<p className="text-lg sm:text-xl text-secondary max-w-2xl mx-auto">
One Click Deployment Platform
</p>
<p className="text-muted mt-2">
Simplified unified platform for Kubernetes application deployment and management
</p>
</div>
{/* Category Features */}
<div className="space-y-8 mb-12">
{categories.map((category) => (
<div key={category.key} className="space-y-4">
{/* Category Header */}
<div className="flex flex-col md:flex-row md:items-center md:gap-3 gap-2">
<div className="flex items-center gap-2">
{category.icon}
<h2 className="text-2xl font-semibold text-primary">{category.title}</h2>
</div>
<div className="hidden md:flex h-px flex-1 bg-gradient-to-r from-brand-accent/40 to-transparent" />
<span className="text-sm text-secondary md:text-muted">
{category.description}
</span>
</div>
{/* Feature Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 md:pl-6">
{category.children.map((item) => (
<div
key={item.path}
onClick={() => navigate(item.path)}
className="group relative overflow-hidden bg-dark-lighter/60 border border-dark-border/70 rounded-xl p-5 sm:p-6 transition-all cursor-pointer hover:border-brand-accent/30 hover:shadow-glow hover:-translate-y-0.5"
>
<div className="flex items-start gap-4">
<div className="relative flex-shrink-0 p-2.5 rounded-xl bg-dark/60 border border-dark-border/60 group-hover:border-brand-accent/40 transition-colors">
{item.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-primary mb-1 group-hover:text-brand-accent transition-colors">
{item.title}
</h3>
<p className="text-secondary text-sm leading-relaxed">{item.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-dark-lighter/60 border border-dark-border/70 rounded-xl p-6 sm:p-7 shadow-soft">
<h3 className="text-lg font-semibold text-brand-accent mb-4 flex items-center gap-2">
<Rocket className="w-5 h-5" />
Quick Start Guide
</h3>
<div className="space-y-4 text-secondary text-sm">
<div className="flex items-start gap-3">
<span className="text-brand-accent font-semibold min-w-[20px]">1.</span>
<div>
<div className="font-semibold text-primary mb-1">
Configuration Clusters
</div>
<div className="text-muted">Add kubeconfig and contexts</div>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-brand-accent font-semibold min-w-[20px]">2.</span>
<div>
<div className="font-semibold text-primary mb-1">
Configuration Registries
</div>
<div className="text-muted">Add OCI or Helm registries</div>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-brand-accent font-semibold min-w-[20px]">3.</span>
<div>
<div className="font-semibold text-primary mb-1">
Artifact Registries
</div>
<div className="text-muted">Browse available charts and images</div>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-brand-accent font-semibold min-w-[20px]">4.</span>
<div>
<div className="font-semibold text-primary mb-1">
Artifact Instances
</div>
<div className="text-muted">Deploy applications to clusters</div>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-brand-accent font-semibold min-w-[20px]">5.</span>
<div>
<div className="font-semibold text-primary mb-1">
Cluster Monitoring
</div>
<div className="text-muted">Monitor cluster status in real-time</div>
</div>
</div>
</div>
</div>
<div className="bg-dark-lighter/60 border border-dark-border/70 rounded-xl p-6 sm:p-7 shadow-soft">
<h3 className="text-lg font-semibold text-brand-accent mb-4 flex items-center gap-2">
<Boxes className="w-5 h-5" />
Core Features
</h3>
<ul className="space-y-3 text-secondary text-sm">
<li className="flex items-start gap-3">
<span className="text-brand-accent">📦</span>
<div>
<strong className="text-primary">
Complete Application Lifecycle
</strong>
<p className="text-muted mt-1">
From artifacts to deployment, unified management
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="text-brand-accent">🔧</span>
<div>
<strong className="text-primary">
Multi-Cluster Configuration
</strong>
<p className="text-muted mt-1">
Support for multiple environments and cluster switching
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="text-brand-accent">📊</span>
<div>
<strong className="text-primary">
Real-time Monitoring
</strong>
<p className="text-muted mt-1">
Integrated Grafana for visualization
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="text-brand-accent">🚀</span>
<div>
<strong className="text-primary">
One-Click Deployment
</strong>
<p className="text-muted mt-1">
Streamlined deployment process for efficiency
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default HomePage;

View File

@ -1,235 +0,0 @@
/**
* Cluster Monitor Card Component
* 显示单个集群的监控信息
*/
import React, { useState } from "react";
import { Activity, CheckCircle, AlertTriangle, XCircle, HelpCircle, Clock, Cpu, Database, Server as ServerIcon, ChevronDown, ChevronUp, TrendingUp } from "lucide-react";
import { Card, Badge } from "@/shared/components";
import type { ClusterMetrics } from "@/core/types";
import { NodeMetricCard } from "./NodeMetricCard";
interface ClusterMonitorCardProps {
cluster: ClusterMetrics;
}
export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster }) => {
const [showNodes, setShowNodes] = useState(false);
const status = cluster.status ?? "unknown";
const uptime = cluster.uptime ?? "N/A";
const nodeCount = cluster.nodeCount ?? 0;
const podCount = cluster.podCount ?? 0;
const totalGpu = cluster.totalGpu ?? 0;
const usedGpu = cluster.usedGpu ?? 0;
const cpuUsage = cluster.cpuUsage ?? 0;
const memoryUsage = cluster.memoryUsage ?? 0;
const gpuUsage = cluster.gpuUsage ?? 0;
const usedCpu = cluster.usedCpu ?? "N/A";
const totalCpu = cluster.totalCpu ?? "N/A";
const usedMemory = cluster.usedMemory ?? "N/A";
const totalMemory = cluster.totalMemory ?? "N/A";
const lastCheckedText = cluster.lastCheck ? new Date(cluster.lastCheck).toLocaleString() : "N/A";
const getStatusBadge = () => {
switch (status) {
case "healthy":
return <Badge variant="success">Healthy</Badge>;
case "warning":
case "unknown":
return <Badge variant="warning">Warning</Badge>;
case "error":
case "unhealthy":
return <Badge variant="danger">Error</Badge>;
default:
return <Badge variant="gray">Unknown</Badge>;
}
};
const getStatusIcon = () => {
switch (status) {
case "healthy":
return <CheckCircle className="w-5 h-5 text-green-400" />;
case "warning":
case "unknown":
return <AlertTriangle className="w-5 h-5 text-yellow-400" />;
case "error":
case "unhealthy":
return <XCircle className="w-5 h-5 text-red-400" />;
default:
return <HelpCircle className="w-5 h-5 text-gray-400" />;
}
};
return (
<Card className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
{/* Status Icon */}
<div className="p-3 bg-gray-800 rounded-lg">
{getStatusIcon()}
</div>
{/* Cluster Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white truncate">{cluster.clusterName || "Unnamed Cluster"}</h3>
{getStatusBadge()}
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-3">
<div>
<p className="text-xs text-gray-500">Uptime</p>
<p className="text-sm text-gray-300 font-mono mt-1">{uptime}</p>
</div>
<div>
<p className="text-xs text-gray-500">Nodes</p>
<div className="flex items-center gap-1 mt-1">
<ServerIcon className="w-3 h-3 text-blue-400" />
<p className="text-sm text-gray-300 font-mono">{nodeCount}</p>
</div>
</div>
<div>
<p className="text-xs text-gray-500">Pods</p>
<p className="text-sm text-gray-300 font-mono mt-1">{podCount}</p>
</div>
<div>
<p className="text-xs text-gray-500">GPU</p>
<p className="text-sm text-gray-300 font-mono mt-1">
{usedGpu}/{totalGpu || "N/A"}
</p>
</div>
</div>
{/* Resource Usage */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3 p-3 bg-gray-800/50 rounded-lg">
<div>
<div className="flex items-center gap-2 mb-1">
<Cpu className="w-3 h-3 text-blue-400" />
<p className="text-xs text-gray-500">CPU (Cluster Total)</p>
</div>
<p className="text-sm text-gray-300 font-mono">{usedCpu} / {totalCpu}</p>
<div className="mt-1 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(cpuUsage, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{cpuUsage.toFixed(1)}%</p>
{cluster.maxNodeCpu && (
<div className="mt-1.5 pt-1.5 border-t border-gray-700/50">
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-blue-400/60" />
<p className="text-xs text-gray-500">Max per node</p>
</div>
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeCpu}</p>
{cluster.maxNodeCpuUsage && cluster.maxNodeCpuUsage > 0 && (
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeCpuUsage.toFixed(1)}%</p>
)}
</div>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Database className="w-3 h-3 text-green-400" />
<p className="text-xs text-gray-500">Memory (Cluster Total)</p>
</div>
<p className="text-sm text-gray-300 font-mono">{usedMemory} / {totalMemory}</p>
<div className="mt-1 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${Math.min(memoryUsage, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{memoryUsage.toFixed(1)}%</p>
{cluster.maxNodeMemory && (
<div className="mt-1.5 pt-1.5 border-t border-gray-700/50">
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-green-400/60" />
<p className="text-xs text-gray-500">Max per node</p>
</div>
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeMemory}</p>
{cluster.maxNodeMemUsage && cluster.maxNodeMemUsage > 0 && (
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeMemUsage.toFixed(1)}%</p>
)}
</div>
)}
</div>
{totalGpu > 0 && (
<div>
<div className="flex items-center gap-2 mb-1">
<Activity className="w-3 h-3 text-purple-400" />
<p className="text-xs text-gray-500">GPU (Cluster Total)</p>
</div>
<p className="text-sm text-gray-300 font-mono">{usedGpu} / {totalGpu}</p>
<div className="mt-1 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full transition-all"
style={{ width: `${Math.min(gpuUsage, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{gpuUsage.toFixed(1)}%</p>
{cluster.maxNodeGpu && cluster.maxNodeGpu > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-gray-700/50">
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-purple-400/60" />
<p className="text-xs text-gray-500">Max per node</p>
</div>
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeGpu} GPUs</p>
{cluster.maxNodeGpuUsage && cluster.maxNodeGpuUsage > 0 && (
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeGpuUsage.toFixed(1)}%</p>
)}
</div>
)}
</div>
)}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
<Clock className="w-3 h-3" />
<span>Last checked: {lastCheckedText}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
{cluster.nodes && cluster.nodes.length > 0 && (
<button
onClick={() => setShowNodes(!showNodes)}
className="px-3 py-1.5 text-sm text-blue-400 hover:text-blue-300 hover:bg-blue-400/10 rounded-lg transition flex items-center gap-2"
>
{showNodes ? (
<>
<ChevronUp className="w-4 h-4" />
Hide Nodes
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Show Nodes ({cluster.nodes.length})
</>
)}
</button>
)}
</div>
</div>
{/* Nodes List */}
{showNodes && cluster.nodes && cluster.nodes.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-700/50">
<h4 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ServerIcon className="w-4 h-4 text-blue-400" />
Cluster Nodes ({cluster.nodes.length})
</h4>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{cluster.nodes.map((node) => (
<NodeMetricCard key={node.nodeName} node={node} />
))}
</div>
</div>
)}
</Card>
);
};

View File

@ -1,147 +0,0 @@
/**
* Node Metric Card Component
* 显示单个节点的监控信息
*/
import React from "react";
import { Server, Cpu, Database, CheckCircle, XCircle, Activity } from "lucide-react";
import { Badge } from "@/shared/components";
import type { NodeMetrics } from "@/core/types";
interface NodeMetricCardProps {
node: NodeMetrics;
}
export const NodeMetricCard: React.FC<NodeMetricCardProps> = ({ node }) => {
const getStatusBadge = () => {
if (node.status === "Ready") {
return <Badge variant="success">Ready</Badge>;
}
return <Badge variant="danger">NotReady</Badge>;
};
const getStatusIcon = () => {
if (node.status === "Ready") {
return <CheckCircle className="w-4 h-4 text-green-400" />;
}
return <XCircle className="w-4 h-4 text-red-400" />;
};
const getRoleBadge = () => {
if (node.role === "control-plane") {
return <Badge variant="blue">Control Plane</Badge>;
}
return <Badge variant="gray">Worker</Badge>;
};
const cpuPercent = node.cpuPercent ?? 0;
const memoryPercent = node.memoryPercent ?? 0;
const gpuPercent = node.gpuPercent ?? 0;
const gpuCapacity = node.gpuCapacity ?? 0;
return (
<div className="p-4 bg-gray-800/30 rounded-lg border border-gray-700/50 hover:border-gray-600/50 transition">
{/* Node Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-700/50 rounded">
<Server className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-semibold text-white">{node.nodeName}</h4>
{getStatusIcon()}
</div>
<div className="flex items-center gap-2">
{getStatusBadge()}
{getRoleBadge()}
</div>
</div>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Age</p>
<p className="text-xs text-gray-300 font-mono">{node.age}</p>
</div>
</div>
{/* Node Metrics Grid */}
<div className="grid grid-cols-3 gap-3">
{/* CPU */}
<div>
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3 h-3 text-blue-400" />
<p className="text-xs text-gray-500">CPU</p>
</div>
<p className="text-xs text-gray-300 font-mono mb-1">
{node.cpuUsage ?? "N/A"} / {node.cpuAllocatable ?? "N/A"}
</p>
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-0.5">{cpuPercent.toFixed(1)}%</p>
</div>
{/* Memory */}
<div>
<div className="flex items-center gap-1.5 mb-1">
<Database className="w-3 h-3 text-green-400" />
<p className="text-xs text-gray-500">Memory</p>
</div>
<p className="text-xs text-gray-300 font-mono mb-1">
{node.memoryUsage ?? "N/A"} / {node.memoryAllocatable ?? "N/A"}
</p>
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${Math.min(memoryPercent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-0.5">{memoryPercent.toFixed(1)}%</p>
</div>
{/* GPU */}
<div>
<div className="flex items-center gap-1.5 mb-1">
<Activity className="w-3 h-3 text-purple-400" />
<p className="text-xs text-gray-500">GPU</p>
</div>
{gpuCapacity > 0 ? (
<>
<p className="text-xs text-gray-300 font-mono mb-1">
{node.gpuUsage ?? "N/A"} / {gpuCapacity}
</p>
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full transition-all"
style={{ width: `${Math.min(gpuPercent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-0.5">
{gpuPercent.toFixed(1)}%
{node.gpuType && <span className="ml-1 text-gray-500">({node.gpuType})</span>}
</p>
</>
) : (
<p className="text-xs text-gray-500 mt-1">No GPU</p>
)}
</div>
</div>
{/* Additional Info */}
<div className="mt-3 pt-3 border-t border-gray-700/50 grid grid-cols-2 gap-2">
<div>
<p className="text-xs text-gray-500">Pods</p>
<p className="text-xs text-gray-300 font-mono">{node.podCount ?? 0}</p>
</div>
{node.kubeletVersion && (
<div>
<p className="text-xs text-gray-500">Kubelet</p>
<p className="text-xs text-gray-300 font-mono">{node.kubeletVersion}</p>
</div>
)}
</div>
</div>
);
};

View File

@ -1,8 +0,0 @@
/**
* Monitoring Feature Module
* 监控功能模块
*/
export { default as MonitoringClustersPage } from "./pages/MonitoringClustersPage";
export { ClusterMonitorCard } from "./components/ClusterMonitorCard";

View File

@ -1,175 +0,0 @@
/**
* Monitoring - Clusters Page
* 监控集群状态和健康信息
*/
import React, { useState, useEffect } from "react";
import { Activity, Server, RefreshCw } from "lucide-react";
import { PageHeader, StatsCard, Button, LoadingState, ErrorState, EmptyState } from "@/shared";
import { useToast } from "@/shared";
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
import { listClusterMonitoring } from "@/api";
import type { ClusterMetrics } from "@/core/types";
import { ClusterMonitorCard } from "../components/ClusterMonitorCard";
const MonitoringClustersPage: React.FC = () => {
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
const [clusters, setClusters] = useState<ClusterMetrics[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
// Load cluster monitoring data
const loadClusters = async (isMounted = { current: true }, isRefresh = false) => {
let succeeded = false;
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await listClusterMonitoring();
if (isMounted.current) {
setClusters(data);
succeeded = true;
}
} catch (err) {
const errorMsg = formatApiError(err) || ClusterErrors.LOAD_FAILED;
if (isMounted.current) {
setError(errorMsg);
toastError(errorMsg);
console.error(err);
}
} finally {
if (isMounted.current) {
setLoading(false);
setRefreshing(false);
}
}
return succeeded;
};
useEffect(() => {
const isMounted = { current: true };
loadClusters(isMounted);
// Auto-refresh every 30 seconds
const interval = setInterval(() => {
loadClusters(isMounted, true);
}, 30000);
return () => {
isMounted.current = false;
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh clusters
const handleRefresh = async () => {
toastInfo("Refreshing cluster metrics...", {
title: "Monitoring Refresh",
durationMs: 1800,
mergeKey: "monitoring-refresh",
});
const refreshed = await loadClusters({ current: true }, true);
if (refreshed) {
toastSuccess(SuccessMessages.DATA_REFRESHED);
}
};
if (loading) {
return <LoadingState message="Loading cluster monitoring data..." />;
}
if (error) {
return (
<ErrorState
title="Failed to Load Clusters"
message={error}
onRetry={() => loadClusters({ current: true })}
/>
);
}
if (clusters.length === 0) {
return (
<EmptyState
icon={Server}
title="No Clusters Available"
description="No clusters configured for monitoring. Please add clusters in the configuration section."
/>
);
}
const healthyCount = clusters.filter(c => c.status === "healthy").length;
const warningCount = clusters.filter(c => c.status === "warning" || c.status === "unknown").length;
const errorCount = clusters.filter(c => c.status === "error" || c.status === "unhealthy").length;
return (
<div className="space-y-6">
{/* Page Header */}
<PageHeader
title="Cluster Monitoring"
description="Monitor cluster health and status"
icon={Activity}
>
<Button
variant="secondary"
icon={RefreshCw}
onClick={handleRefresh}
loading={refreshing}
>
{refreshing ? "Refreshing..." : "Refresh"}
</Button>
</PageHeader>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatsCard
title="Total Clusters"
value={clusters.length}
icon={Server}
variant="blue"
/>
<StatsCard
title="Healthy"
value={healthyCount}
icon={Activity}
variant="green"
/>
<StatsCard
title="Warning"
value={warningCount}
icon={Activity}
variant="orange"
/>
<StatsCard
title="Error"
value={errorCount}
icon={Activity}
variant="red"
/>
</div>
{/* Auto-refresh Info */}
<div className="text-sm text-gray-400">
Auto-refresh every 30 seconds {refreshing && "• Refreshing..."}
</div>
{/* Cluster List */}
<div className="grid gap-4">
{clusters.map((cluster, index) => (
<ClusterMonitorCard
key={cluster.clusterId || cluster.id || `${cluster.clusterName || 'cluster'}-${index}`}
cluster={cluster}
/>
))}
</div>
</div>
);
};
export default MonitoringClustersPage;

View File

@ -1,9 +0,0 @@
/**
* Monitoring Module
* 监控模块 - 集群监控
*/
// Clusters
export { default as MonitoringClustersPage } from './clusters/pages/MonitoringClustersPage';
export * from './clusters/components/ClusterMonitorCard';

View File

@ -1,9 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn { animation: fadeIn 0.25s ease-out; }

323
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,323 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
UserDTO,
UserListResponse,
WorkspaceDTO,
WorkspaceResponse,
WorkspaceListResponse,
CreateWorkspaceRequest,
UpdateWorkspaceRequest,
SetQuotasRequest,
QuotaDTO,
ClusterDTO,
CreateClusterRequest,
UpdateClusterRequest,
ClusterHealthResponse,
RegistryDTO,
CreateRegistryRequest,
UpdateRegistryRequest,
RegistryHealthResponse,
Repository,
Artifact,
ArtifactValues,
ArtifactValuesSchema,
InstanceDTO,
CreateInstanceRequest,
UpdateInstanceRequest,
ClusterMonitoring,
NodeMetric,
MonitoringSummary,
StorageDTO,
CreateStorageRequest,
UpdateStorageRequest,
ChartReferenceDTO,
CreateChartReferenceRequest,
UpdateChartReferenceRequest,
ValuesTemplateDTO,
CreateValuesTemplateRequest,
UpdateValuesTemplateRequest,
} from './types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Response interceptor to handle 401
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// Auth API - 使用 any 类型因为后端响应被包装在 data 字段中
export const authApi = {
login: (data: LoginRequest) =>
api.post<any>('/auth/login', data),
register: (data: RegisterRequest) =>
api.post<any>('/auth/register', data),
refreshToken: (refreshToken: string) =>
api.post<any>('/auth/refresh', { refreshToken }),
getCurrentUser: () =>
api.get<any>('/users/me'),
changePassword: (oldPassword: string, newPassword: string) =>
api.put('/users/me/password', { old_password: oldPassword, new_password: newPassword }),
getCurrentUserWorkspace: () =>
api.get<any>('/users/me/workspace'),
};
// Admin User API - 使用 any 类型避免类型不匹配问题,因为后端响应被包装在 data 字段中
export const adminApi = {
listUsers: (workspaceId?: string) =>
api.get<any>('/admin/users', { params: { workspace_id: workspaceId } }),
createUser: (data: {
username: string;
password: string;
email?: string;
role: string;
workspace_id?: string;
}) =>
api.post<any>('/admin/users', data),
getUser: (userId: string) =>
api.get<any>(`/admin/users/${userId}`),
updateUser: (userId: string, data: { email?: string; is_active?: boolean }) =>
api.put<any>(`/admin/users/${userId}`, data),
setUserActive: (userId: string, isActive: boolean) =>
api.put<any>(`/admin/users/${userId}/active`, { is_active: isActive }),
changeUserWorkspace: (userId: string, workspaceId: string) =>
api.put(`/admin/users/${userId}/workspace`, { workspace_id: workspaceId }),
resetPassword: (userId: string, newPassword: string) =>
api.put(`/admin/users/${userId}/password`, { new_password: newPassword }),
deleteUser: (userId: string) =>
api.delete(`/admin/users/${userId}`),
};
// Workspace API
export const workspaceApi = {
list: () =>
api.get<any>('/workspaces'),
create: (data: CreateWorkspaceRequest) =>
api.post<any>('/workspaces', data),
get: (workspaceId: string) =>
api.get<any>(`/workspaces/${workspaceId}`),
update: (workspaceId: string, data: UpdateWorkspaceRequest) =>
api.put<any>(`/workspaces/${workspaceId}`, data),
delete: (workspaceId: string) =>
api.delete(`/workspaces/${workspaceId}`),
getQuotas: (workspaceId: string) =>
api.get<any>(`/workspaces/${workspaceId}/quotas`),
setQuotas: (workspaceId: string, data: SetQuotasRequest) =>
api.put<any>(`/workspaces/${workspaceId}/quotas`, data),
};
// Cluster API
export const clusterApi = {
list: () =>
api.get<any>('/clusters'),
create: (data: CreateClusterRequest) =>
api.post<any>('/clusters', data),
get: (clusterId: string) =>
api.get<any>(`/clusters/${clusterId}`),
update: (clusterId: string, data: UpdateClusterRequest) =>
api.put<any>(`/clusters/${clusterId}`, data),
delete: (clusterId: string) =>
api.delete(`/clusters/${clusterId}`),
getHealth: (clusterId: string) =>
api.get<any>(`/clusters/${clusterId}/health`),
};
// Registry API
export const registryApi = {
list: () =>
api.get<any>('/registries'),
create: (data: CreateRegistryRequest) =>
api.post<any>('/registries', data),
get: (registryId: string) =>
api.get<any>(`/registries/${registryId}`),
update: (registryId: string, data: UpdateRegistryRequest) =>
api.put<any>(`/registries/${registryId}`, data),
delete: (registryId: string) =>
api.delete(`/registries/${registryId}`),
getHealth: (registryId: string) =>
api.get<any>(`/registries/${registryId}/health`),
listRepositories: (registryId: string) =>
api.get<any>(`/registries/${registryId}/repositories`),
listArtifacts: (registryId: string, repositoryName: string) =>
api.get<any>(`/registries/${registryId}/repositories/${encodeURIComponent(repositoryName)}/artifacts`),
getArtifact: (registryId: string, repositoryName: string, reference: string) =>
api.get<any>(`/registries/${registryId}/repositories/${encodeURIComponent(repositoryName)}/artifacts/${reference}`),
getArtifactValues: (registryId: string, repositoryName: string, reference: string) =>
api.get<any>(`/registries/${registryId}/repositories/${encodeURIComponent(repositoryName)}/artifacts/${reference}/values`),
getArtifactValuesSchema: (registryId: string, repositoryName: string, reference: string) =>
api.get<any>(`/registries/${registryId}/repositories/${encodeURIComponent(repositoryName)}/artifacts/${reference}/values-schema`),
};
// Instance API
export const instanceApi = {
list: (clusterId: string) =>
api.get<any>(`/clusters/${clusterId}/instances`),
create: (clusterId: string, data: CreateInstanceRequest) =>
api.post<any>(`/clusters/${clusterId}/instances`, data),
get: (clusterId: string, instanceId: string) =>
api.get<any>(`/clusters/${clusterId}/instances/${instanceId}`),
update: (clusterId: string, instanceId: string, data: UpdateInstanceRequest) =>
api.put<any>(`/clusters/${clusterId}/instances/${instanceId}`, data),
delete: (clusterId: string, instanceId: string) =>
api.delete(`/clusters/${clusterId}/instances/${instanceId}`),
listEntries: (clusterId: string, instanceId: string) =>
api.get<any>(`/clusters/${clusterId}/instances/${instanceId}/entries`),
};
// Monitoring API
export const monitoringApi = {
listClusterMonitoring: () =>
api.get<any>('/monitoring/clusters'),
getClusterMonitoring: (clusterId: string) =>
api.get<any>(`/monitoring/clusters/${clusterId}`),
getNodeMetrics: (clusterId: string) =>
api.get<any>(`/monitoring/clusters/${clusterId}/nodes`),
getSummary: () =>
api.get<any>('/monitoring/summary'),
};
// Storage Backend API
export const storageApi = {
list: () =>
api.get<any>('/storage-backends'),
create: (data: CreateStorageRequest) =>
api.post<any>('/storage-backends', data),
get: (storageId: string) =>
api.get<any>(`/storage-backends/${storageId}`),
update: (storageId: string, data: UpdateStorageRequest) =>
api.put<any>(`/storage-backends/${storageId}`, data),
delete: (storageId: string) =>
api.delete(`/storage-backends/${storageId}`),
};
// Chart Reference API
export const chartReferenceApi = {
list: () =>
api.get<any>('/chart-references'),
create: (data: CreateChartReferenceRequest) =>
api.post<any>('/chart-references', data),
get: (chartRefId: string) =>
api.get<any>(`/chart-references/${chartRefId}`),
update: (chartRefId: string, data: UpdateChartReferenceRequest) =>
api.put<any>(`/chart-references/${chartRefId}`, data),
delete: (chartRefId: string) =>
api.delete(`/chart-references/${chartRefId}`),
};
// Values Template API
export const valuesTemplateApi = {
list: () =>
api.get<any>('/values-templates'),
create: (data: CreateValuesTemplateRequest) =>
api.post<any>('/values-templates', data),
get: (templateId: string) =>
api.get<any>(`/values-templates/${templateId}`),
update: (templateId: string, data: UpdateValuesTemplateRequest) =>
api.put<any>(`/values-templates/${templateId}`, data),
delete: (templateId: string) =>
api.delete(`/values-templates/${templateId}`),
getByChartReference: (chartRefId: string) =>
api.get<any>(`/chart-references/${chartRefId}/values-templates`),
getHistory: (chartRefId: string, name: string) =>
api.get<any>(`/chart-references/${chartRefId}/values-templates/history?name=${encodeURIComponent(name)}`),
rollback: (chartRefId: string, templateId: string) =>
api.post<any>(`/chart-references/${chartRefId}/values-templates/rollback`, { template_id: templateId }),
};
// Logout helper
export const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
};
export default api;

View File

@ -0,0 +1,106 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { authApi } from './api';
import type { UserDTO, LoginRequest } from './types';
interface AuthContextType {
user: UserDTO | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserDTO | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = async () => {
try {
const token = localStorage.getItem('access_token');
if (!token) {
setUser(null);
return;
}
const response = await authApi.getCurrentUser();
const user = response.data.data.user;
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
} catch (error) {
console.error('Failed to refresh user:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setUser(null);
}
};
const login = async (credentials: LoginRequest) => {
const response = await authApi.login(credentials);
// API returns camelCase: accessToken, refreshToken
const access_token = response.data.accessToken;
const refresh_token = response.data.refreshToken;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
// Fetch user info - API returns { message: "", data: { user: {...} } }
const userResponse = await authApi.getCurrentUser();
const user = userResponse.data.data.user;
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
};
const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setUser(null);
};
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem('access_token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
await refreshUser();
} catch {
setUser(null);
}
}
setIsLoading(false);
};
initAuth();
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

403
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,403 @@
// API Types matching backend DTOs
// Auth Types
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
expires_in?: number;
must_change_password?: boolean;
}
export interface RegisterRequest {
username: string;
password: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
// User Types
export interface UserDTO {
id: string;
username: string;
email?: string;
role: string;
workspace_id?: string;
workspace_name?: string;
is_active: boolean;
must_change_password: boolean;
created_at: string;
updated_at: string;
}
// API 响应包装类型 - 后端所有响应都使用 respondSuccess 包装
export interface ApiResponse<T> {
message: string;
data: T;
}
export interface UserListResponse {
users: UserDTO[];
total: number;
}
// Workspace Types
export interface WorkspaceDTO {
id: string;
name: string;
description?: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface QuotaDTO {
id: string;
workspace_id: string;
resource_type: string;
hard_limit: number;
soft_limit: number;
used: number;
}
export interface WorkspaceResponse {
workspace: WorkspaceDTO;
quotas?: QuotaDTO[];
}
export interface WorkspaceListResponse {
workspaces: WorkspaceDTO[];
total: number;
}
export interface CreateWorkspaceRequest {
name: string;
description?: string;
}
export interface UpdateWorkspaceRequest {
name?: string;
description?: string;
}
export interface SetQuotasRequest {
cpu?: { hard_limit: number; soft_limit: number };
gpu?: { hard_limit: number; soft_limit: number };
gpu_memory?: { hard_limit: number; soft_limit: number };
}
// Cluster Types
export interface ClusterDTO {
id: string;
workspaceId?: string;
ownerId?: string;
name: string;
host: string;
description?: string;
isolationMode?: string;
defaultNamespace?: string;
isShared: boolean;
hasCaData: boolean;
hasCertData: boolean;
hasKeyData: boolean;
hasToken: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateClusterRequest {
name: string;
host: string;
caData?: string;
ca_data?: string;
certData?: string;
cert_data?: string;
keyData?: string;
key_data?: string;
token?: string;
description?: string;
isolationMode?: string;
defaultNamespace?: string;
isShared?: boolean;
}
export interface UpdateClusterRequest extends Partial<CreateClusterRequest> {}
export interface ClusterHealthResponse {
healthy: boolean;
message?: string;
version?: string;
}
// Registry Types
export interface RegistryDTO {
id: string;
workspaceId?: string;
ownerId?: string;
name: string;
url: string;
description?: string;
username?: string;
insecure: boolean;
isShared: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateRegistryRequest {
name: string;
url: string;
username?: string;
password?: string;
description?: string;
insecure?: boolean;
isShared?: boolean;
}
export interface UpdateRegistryRequest extends Partial<CreateRegistryRequest> {}
export interface RegistryHealthResponse {
healthy: boolean;
message?: string;
}
// Artifact Types
export interface Repository {
name: string;
artifact_count: number;
}
export interface Artifact {
reference: string;
media_type: string;
size: number;
created_at: string;
}
export interface ArtifactValues {
values_yaml: string;
}
export interface ArtifactValuesSchema {
schema: object;
}
// Instance Types
export interface InstanceDTO {
id: string;
workspaceId?: string;
ownerId?: string;
clusterId: string;
clusterName?: string;
registryId?: string;
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
status: string;
status_reason?: string;
last_operation?: string;
last_error?: string;
revision: number;
createdAt: string;
updatedAt: string;
}
export interface CreateInstanceRequest {
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
values?: object;
values_yaml?: string;
user_override_yaml?: string;
}
export interface UpdateInstanceRequest extends Partial<CreateInstanceRequest> {}
// Monitoring Types
export interface ClusterMonitoring {
clusterId: string;
clusterName: string;
status: string;
cpuUsage: number;
memoryUsage: number;
nodeCount: number;
podCount: number;
uptime?: string;
totalCpu?: string;
totalMemory?: string;
totalGpu?: number;
usedCpu?: string;
usedMemory?: string;
usedGpu?: number;
gpuUsage?: number;
nodes?: NodeMetric[];
}
export interface NodeMetric {
nodeName: string;
status: string;
role: string;
age: string;
podCount: number;
cpuCapacity: string;
cpuAllocatable: string;
cpuUsage: string;
cpuPercent: number;
memoryCapacity: string;
memoryAllocatable: string;
memoryUsage: string;
memoryPercent: number;
gpuCapacity?: number;
gpuUsage?: number;
gpuPercent?: number;
gpuType?: string;
osImage?: string;
kernelVersion?: string;
containerRuntime?: string;
kubeletVersion?: string;
}
export interface MonitoringSummary {
totalClusters: number;
healthyClusters: number;
warningClusters?: number;
errorClusters?: number;
totalInstances?: number;
runningInstances?: number;
totalNodes: number;
totalPods?: number;
lastUpdate?: string;
}
// Storage Backend types
export interface NFSConfig {
server: string;
path: string;
}
export interface PVConfig {
storageClassName: string;
capacity: string;
accessModes: string[];
}
export interface HostPathConfig {
path: string;
}
export interface StorageConfig {
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
}
export interface StorageDTO {
id: string;
workspace_id?: string;
owner_id?: string;
name: string;
type: string; // 'nfs', 'pv', 'hostPath'
config: StorageConfig;
description?: string;
is_default: boolean;
is_shared: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateStorageRequest {
name: string;
type: string;
description?: string;
is_default?: boolean;
is_shared?: boolean;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
}
export interface UpdateStorageRequest {
name?: string;
type?: string;
description?: string;
is_default?: boolean;
is_shared?: boolean;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
}
// Chart Reference Types
export interface ChartReferenceDTO {
id: string;
workspace_id?: string;
owner_id?: string;
registry_id: string;
registry_name?: string;
repository: string;
chart_name: string;
description?: string;
is_enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateChartReferenceRequest {
registry_id: string;
repository: string;
chart_name: string;
description?: string;
is_enabled?: boolean;
}
export interface UpdateChartReferenceRequest {
description?: string;
is_enabled?: boolean;
}
// Values Template Types
export interface ValuesTemplateDTO {
id: string;
workspace_id?: string;
owner_id?: string;
chart_reference_id: string;
chart_name?: string;
name: string;
description?: string;
values_yaml: string;
version: number;
is_default: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateValuesTemplateRequest {
chart_reference_id: string;
name: string;
description?: string;
values_yaml: string;
is_default?: boolean;
}
export interface UpdateValuesTemplateRequest {
description?: string;
values_yaml?: string;
is_default?: boolean;
}
export interface RollbackValuesTemplateRequest {
template_id: string;
}

View File

@ -1,24 +0,0 @@
// Must import reflect-metadata first for class-transformer decorators to work
import "reflect-metadata";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App, AuthProvider } from "./app";
import { ToastProvider } from "./shared/components/feedback/ToastProvider";
import "./index.css";
// Dev mode: auto login, skip authentication
const DEV_MODE = import.meta.env.DEV && true; // Set to false to restore normal login
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider devMode={DEV_MODE}>
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -1,72 +0,0 @@
/**
* 统一的 StatsCard 组件
* 用于显示统计数据卡片
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
export type StatsCardVariant = "blue" | "green" | "purple" | "orange" | "red";
export interface StatsCardProps {
title: string;
value: string | number;
icon: LucideIcon;
variant?: StatsCardVariant;
subtitle?: string;
className?: string;
}
const variantStyles: Record<StatsCardVariant, { container: string; iconBg: string; iconColor: string }> = {
blue: {
container: "bg-gradient-to-br from-blue-600/20 to-blue-800/20 border-blue-500/30",
iconBg: "bg-blue-600/30",
iconColor: "text-blue-300",
},
green: {
container: "bg-gradient-to-br from-green-600/20 to-green-800/20 border-green-500/30",
iconBg: "bg-green-600/30",
iconColor: "text-green-300",
},
purple: {
container: "bg-gradient-to-br from-purple-600/20 to-purple-800/20 border-purple-500/30",
iconBg: "bg-purple-600/30",
iconColor: "text-purple-300",
},
orange: {
container: "bg-gradient-to-br from-orange-600/20 to-orange-800/20 border-orange-500/30",
iconBg: "bg-orange-600/30",
iconColor: "text-orange-300",
},
red: {
container: "bg-gradient-to-br from-red-600/20 to-red-800/20 border-red-500/30",
iconBg: "bg-red-600/30",
iconColor: "text-red-300",
},
};
export const StatsCard: React.FC<StatsCardProps> = ({
title,
value,
icon: Icon,
variant = "blue",
subtitle,
className = "",
}) => {
const styles = variantStyles[variant];
return (
<div className={`border rounded-lg p-4 ${styles.container} ${className}`.trim()}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${styles.iconBg}`}>
<Icon className={`w-6 h-6 ${styles.iconColor}`} />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-sm text-gray-400">{title}</p>
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
</div>
</div>
</div>
);
};

View File

@ -1,8 +0,0 @@
/**
* Data Display Components - Components for Displaying Data
* 数据展示组件 - 用于展示数据的组件
*/
export { StatsCard, type StatsCardProps, type StatsCardVariant } from "./StatsCard";

View File

@ -1,45 +0,0 @@
/**
* 统一的 EmptyState 组件
* 用于显示空状态
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
import { Button } from "../ui/Button";
export interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
};
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon: Icon,
title,
description,
action,
}) => {
return (
<div className="text-center py-16 bg-gray-800/30 border border-gray-700 border-dashed rounded-lg">
{Icon && <Icon className="w-16 h-16 mx-auto mb-4 text-gray-600" />}
<h3 className="text-lg font-semibold text-gray-400 mb-2">{title}</h3>
{description && (
<p className="text-sm text-gray-500 mb-6">{description}</p>
)}
{action && (
<Button
variant="primary"
icon={action.icon}
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
);
};

View File

@ -1,33 +0,0 @@
/**
* 配置性界面的空状态组件(无引导按钮)
* 仅提示用户当前没有配置数据
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
interface EmptyStateSimpleProps {
/** 标题 */
title: string;
/** 描述文字 */
description: string;
/** 图标组件 */
Icon: LucideIcon;
/** 自定义样式(可选) */
className?: string;
}
export const EmptyStateSimple: React.FC<EmptyStateSimpleProps> = ({
title,
description,
Icon,
className = "",
}) => {
return (
<div className={`bg-gray-800/30 border border-gray-700 rounded-lg p-8 text-center ${className}`}>
<Icon className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-300 mb-2">{title}</h3>
<p className="text-gray-400">{description}</p>
</div>
);
};

View File

@ -1,54 +0,0 @@
/**
* 使用性界面的空状态组件(带引导按钮)
* 当缺少配置时,引导用户前往对应的配置页面
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
interface EmptyStateWithGuideProps {
/** 标题 */
title: string;
/** 描述文字 */
description: string;
/** 引导按钮文字 */
buttonText: string;
/** 引导目标路径 */
targetPath: string;
/** 图标组件 */
Icon: LucideIcon;
}
export const EmptyStateWithGuide: React.FC<EmptyStateWithGuideProps> = ({
title,
description,
buttonText,
targetPath,
Icon,
}) => {
const navigate = useNavigate();
return (
<div className="bg-yellow-900/20 border border-yellow-700/50 rounded-lg p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-300 mb-2">{title}</h3>
<p className="text-yellow-200 mb-4">{description}</p>
<button
onClick={() => navigate(targetPath)}
className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition"
>
<Icon className="w-4 h-4" />
{buttonText}
</button>
</div>
</div>
</div>
);
};

View File

@ -1,38 +0,0 @@
/**
* 统一的 ErrorState 组件
* 用于显示错误状态
*/
import React from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "../ui/Button";
import { ButtonText, ErrorText } from "../../constants";
export interface ErrorStateProps {
title?: string;
message: string;
onRetry?: () => void;
retryLabel?: string;
}
export const ErrorState: React.FC<ErrorStateProps> = ({
title = ErrorText.ERROR_OCCURRED,
message,
onRetry,
retryLabel = ButtonText.RETRY,
}) => {
return (
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-500/50 rounded-lg text-red-400">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<div className="flex-1">
{title && <p className="font-medium">{title}</p>}
<p className={title ? "text-sm mt-1" : ""}>{message}</p>
</div>
{onRetry && (
<Button variant="danger" size="sm" onClick={onRetry}>
{retryLabel}
</Button>
)}
</div>
);
};

View File

@ -1,45 +0,0 @@
/**
* 统一的 LoadingState 组件
* 用于显示加载状态
*/
import React from "react";
export interface LoadingStateProps {
message?: string;
size?: "sm" | "md" | "lg";
fullScreen?: boolean;
}
const sizeStyles = {
sm: "w-6 h-6 border-2",
md: "w-12 h-12 border-4",
lg: "w-16 h-16 border-4",
};
export const LoadingState: React.FC<LoadingStateProps> = ({
message = "Loading...",
size = "md",
fullScreen = false,
}) => {
const content = (
<div className="flex flex-col items-center justify-center gap-4">
<div className={`${sizeStyles[size]} border-purple-600 border-t-transparent rounded-full animate-spin`} />
{message && <p className="text-sm text-gray-400">{message}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm z-50">
{content}
</div>
);
}
return (
<div className="flex items-center justify-center py-12">
{content}
</div>
);
};

View File

@ -1,28 +0,0 @@
/**
* Toast Context
* Toast context definition - separated for Fast Refresh compatibility
*/
import { createContext } from "react";
export type ToastOptions = {
variant?: "success" | "error" | "warning" | "info";
title?: string;
description?: string;
durationMs?: number; // 0 = 不自动关闭(需要手动关闭)
id?: string; // 自定义 id你想手动控制唯一性时用
/** 指定"去重/合并"的键;不填则用 variant|title|description 自动生成 */
mergeKey?: string;
};
export type ToastContextValue = {
show: (opts: ToastOptions) => void;
success: (msg: string, opts?: Partial<ToastOptions>) => void;
error: (msg: string, opts?: Partial<ToastOptions>) => void;
warning: (msg: string, opts?: Partial<ToastOptions>) => void;
info: (msg: string, opts?: Partial<ToastOptions>) => void;
remove: (id: string) => void;
};
export const ToastContext = createContext<ToastContextValue | null>(null);

View File

@ -1,270 +0,0 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { CheckCircle2, Info, AlertTriangle, X, XCircle } from "lucide-react";
import { ToastContext, type ToastOptions, type ToastContextValue } from "./ToastContext";
/** ===================== 可调默认项 ===================== */
const DEFAULT_DURATION = 4500; // 默认显示时长(毫秒)
const ERROR_DURATION = 6000; // 错误类默认更久
const EXTEND_ON_DUP_MS = 1000; // 合并重复时延长的时间(毫秒)
const PREVENT_DUPLICATES = true; // 开启自动去重与合并计数
const HOVER_PAUSES_DISMISS = true; // 悬停暂停自动关闭
/** ===================================================== */
type ToastVariant = "success" | "error" | "warning" | "info";
type ToastItem = Required<
Omit<ToastOptions, "id" | "mergeKey">
> & {
id: string;
mergeKey: string;
createdAt: number;
startedAt: number; // 本轮计时开始时间(用于计算剩余)
remainingMs: number; // 剩余时间(暂停时更新)
paused: boolean;
count: number; // 合并计数
};
// useToast hook is now exported from ./useToast.ts for Fast Refresh compatibility
const variantStyle: Record<ToastVariant, string> = {
success: "bg-emerald-600 border-emerald-500",
error: "bg-red-600 border-red-500",
warning: "bg-amber-600 border-amber-500",
info: "bg-sky-600 border-sky-500",
};
const variantIcon: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle2 className="w-5 h-5" />,
error: <XCircle className="w-5 h-5" />,
warning: <AlertTriangle className="w-5 h-5" />,
info: <Info className="w-5 h-5" />,
};
export const ToastProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
// 保存每条 toast 的定时器句柄,避免放进 state
const timers = useRef(new Map<string, number>());
const removeTimer = (id: string) => {
const t = timers.current.get(id);
if (t) {
window.clearTimeout(t);
timers.current.delete(id);
}
};
const remove = useCallback((id: string) => {
removeTimer(id);
setToasts((xs) => xs.filter((t) => t.id !== id));
}, []);
const schedule = (id: string, ms: number) => {
if (ms <= 0) return; // 0 表示不自动关闭
removeTimer(id);
const h = window.setTimeout(() => {
remove(id);
}, ms);
timers.current.set(id, h);
};
const now = () => Date.now();
const makeMergeKey = (opts: ToastOptions) =>
(opts.mergeKey ??
`${opts.variant ?? "info"}|${opts.title ?? ""}|${opts.description ?? ""}`).trim();
const normalizedDuration = (variant?: ToastVariant, provided?: number) => {
if (provided === 0) return 0;
if (typeof provided === "number" && provided > 0) return provided;
return variant === "error" ? ERROR_DURATION : DEFAULT_DURATION;
};
const show = useCallback((opts: ToastOptions) => {
const variant = opts.variant ?? "info";
const durationMs = normalizedDuration(variant, opts.durationMs);
const id = opts.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const mergeKey = makeMergeKey(opts);
setToasts((prev) => {
if (PREVENT_DUPLICATES) {
// 先找是否已存在相同 mergeKey 的 toast
const idx = prev.findIndex((t) => t.mergeKey === mergeKey);
if (idx >= 0) {
const existing = prev[idx];
// 更新计数、时长(延长)、重新计时
const extended = Math.max(
existing.remainingMs,
durationMs > 0 ? durationMs : existing.remainingMs
) + EXTEND_ON_DUP_MS;
const updated: ToastItem = {
...existing,
count: existing.count + 1,
paused: false,
startedAt: now(),
remainingMs: existing.durationMs === 0 ? 0 : extended,
// 如果传入了更大的 durationMs也可以更新基准 duration
durationMs: existing.durationMs === 0 ? 0 : Math.max(existing.durationMs, durationMs),
};
// 重新调度
if (updated.durationMs > 0) {
removeTimer(existing.id);
schedule(existing.id, extended);
}
const next = [...prev];
next[idx] = updated;
return next;
}
}
const item: ToastItem = {
id,
mergeKey,
variant,
title: opts.title ?? "",
description: opts.description ?? "",
durationMs,
createdAt: now(),
startedAt: now(),
remainingMs: durationMs,
paused: false,
count: 1,
};
// 新建 toast 的定时
if (durationMs > 0) schedule(id, durationMs);
return [...prev, item];
});
}, [remove]);
const pause = (id: string) => {
if (!HOVER_PAUSES_DISMISS) return;
setToasts((prev) =>
prev.map((t) => {
if (t.id !== id || t.paused) return t;
// 暂停时计算剩余时间并清计时器
const elapsed = now() - t.startedAt;
const remaining = Math.max(0, t.remainingMs - elapsed);
removeTimer(id);
return { ...t, paused: true, remainingMs: remaining };
})
);
};
const resume = (id: string) => {
if (!HOVER_PAUSES_DISMISS) return;
setToasts((prev) =>
prev.map((t) => {
if (t.id !== id || !t.paused) return t;
const next = { ...t, paused: false, startedAt: now() };
if (next.durationMs > 0 && next.remainingMs > 0) {
schedule(id, next.remainingMs);
}
return next;
})
);
};
const api = useMemo<ToastContextValue>(
() => ({
show,
success: (msg, opts) =>
show({
...opts,
variant: "success",
title: opts?.title ?? "Success",
description: msg,
}),
error: (msg, opts) =>
show({
...opts,
variant: "error",
title: opts?.title ?? "Error",
description: msg,
durationMs: opts?.durationMs ?? ERROR_DURATION,
}),
warning: (msg, opts) =>
show({
...opts,
variant: "warning",
title: opts?.title ?? "Warning",
description: msg,
}),
info: (msg, opts) =>
show({
...opts,
variant: "info",
title: opts?.title ?? "Info",
description: msg,
}),
remove,
}),
[remove, show]
);
return (
<ToastContext.Provider value={api}>
{children}
{/* 容器 - 响应式定位 */}
<div className="fixed z-[200] top-4 sm:top-4 right-2 sm:right-4 left-2 sm:left-auto space-y-2 sm:space-y-3 max-w-sm sm:w-[calc(100vw-2rem)]">
{toasts.map((t) => {
const barEnabled = t.durationMs > 0 && t.remainingMs > 0;
// 估算进度(简单:剩余 / 基准),暂停时保持不动
const elapsed = t.paused ? 0 : Math.max(0, Date.now() - t.startedAt);
const left = Math.max(0, t.remainingMs - elapsed);
const pct = barEnabled ? Math.max(0, Math.min(1, left / Math.max(1, t.durationMs))) : 0;
return (
<div
key={t.id}
className={`border text-white rounded-lg shadow-lg p-2.5 sm:p-3 pt-3 sm:pt-3.5 flex items-start gap-2 sm:gap-3 relative ${variantStyle[t.variant]} animate-fadeIn`}
role="status"
onMouseEnter={() => pause(t.id)}
onMouseLeave={() => resume(t.id)}
>
{/* 顶部进度条(可选、低调) */}
{barEnabled && (
<div
className="absolute left-0 top-0 h-1 bg-white/40 rounded-t-lg transition-[width] duration-200 ease-linear"
style={{ width: `${pct * 100}%` }}
/>
)}
<div className="mt-0.5 flex-shrink-0">{variantIcon[t.variant]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
{t.title && <div className="font-semibold text-sm sm:text-base">{t.title}</div>}
{t.count > 1 && (
<span className="text-[10px] sm:text-xs px-1 sm:px-1.5 py-[1px] sm:py-[2px] rounded bg-black/20 border border-white/20">
×{t.count}
</span>
)}
</div>
{t.description && (
<div className="text-xs sm:text-sm opacity-90 break-words">{t.description}</div>
)}
</div>
<button
onClick={() => remove(t.id)}
className="opacity-80 hover:opacity-100 transition mt-0.5 flex-shrink-0 p-1"
aria-label="Close"
>
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
};

View File

@ -1,18 +0,0 @@
/**
* Feedback Components - User Feedback and State Display
* 反馈组件 - 用户反馈和状态展示
*/
// State displays
export { LoadingState, type LoadingStateProps } from "./LoadingState";
export { ErrorState, type ErrorStateProps } from "./ErrorState";
export { EmptyState, type EmptyStateProps } from "./EmptyState";
export { EmptyStateSimple } from "./EmptyStateSimple";
export { EmptyStateWithGuide } from "./EmptyStateWithGuide";
// Toast notifications
export { ToastProvider } from "./ToastProvider";
export { useToast } from "./useToast";
export type { ToastOptions } from "./ToastContext";

View File

@ -1,14 +0,0 @@
/**
* useToast Hook
* Toast context hook - separated for Fast Refresh compatibility
*/
import { useContext } from "react";
import { ToastContext } from "./ToastContext";
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
return ctx;
};

View File

@ -1,46 +0,0 @@
/**
* 统一的 Checkbox 组件
*/
import React from "react";
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
error?: string;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ label, error, className = "", ...props }, ref) => {
const baseStyles = "w-4 h-4 text-purple-600 bg-gray-800 border-gray-600 rounded focus:ring-2 focus:ring-purple-500 focus:ring-offset-gray-900 cursor-pointer";
return (
<div>
<div className="flex items-center gap-2">
<input
ref={ref}
type="checkbox"
className={`${baseStyles} ${className}`.trim()}
{...props}
/>
{label && (
<label className="text-sm text-gray-300 cursor-pointer select-none" onClick={(e) => {
if (props.id) {
const checkbox = document.getElementById(props.id) as HTMLInputElement;
if (checkbox && e.target === e.currentTarget) {
checkbox.click();
}
}
}}>
{label}
</label>
)}
</div>
{error && (
<p className="mt-1 text-xs text-red-400">{error}</p>
)}
</div>
);
}
);
Checkbox.displayName = "Checkbox";

View File

@ -1,288 +0,0 @@
/**
* Dropdown Select Component
* Custom dropdown without search functionality
* Uses Portal + Fixed positioning to avoid Modal overflow issues
*/
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, X } from 'lucide-react';
export interface DropdownSelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface DropdownSelectProps {
value: string;
onChange: (value: string) => void;
options: DropdownSelectOption[];
placeholder?: string;
required?: boolean;
disabled?: boolean;
className?: string;
zIndex?: number; // Custom z-index for the dropdown
}
export const DropdownSelect: React.FC<DropdownSelectProps> = ({
value,
onChange,
options,
placeholder = 'Select...',
required = false,
disabled = false,
className = '',
zIndex = 110, // Default z-index for dropdown
}) => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Get enabled options
const enabledOptions = options.filter(opt => !opt.disabled);
// Close dropdown on scroll (prevents it from covering modal header)
useEffect(() => {
if (isOpen && containerRef.current) {
const updatePosition = () => {
const rect = containerRef.current!.getBoundingClientRect();
// Always position below the trigger
setDropdownPosition({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
};
updatePosition();
// Close dropdown on scroll (prevents it from covering modal header)
const handleScroll = (e: Event) => {
// Only close if scroll is not from the dropdown itself
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
// Update position on resize, close on scroll
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', updatePosition);
};
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (!isOpen) {
if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ') {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((prev) =>
prev < enabledOptions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (enabledOptions[highlightedIndex]) {
handleSelect(enabledOptions[highlightedIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
// Scroll highlighted item into view
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [highlightedIndex, isOpen]);
const handleSelect = (optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
};
const handleToggle = () => {
if (!disabled) {
setIsOpen(!isOpen);
}
};
// Get display label
const selectedOption = options.find(opt => opt.value === value);
const displayText = selectedOption?.label || '';
// Render dropdown in portal
const renderDropdown = () => {
if (!isOpen) return null;
return createPortal(
<div
ref={dropdownRef}
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-2xl flex flex-col"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
maxHeight: 'min(20rem, 60vh)',
zIndex: zIndex,
}}
role="dialog"
>
{/* Options List */}
<ul
ref={listRef}
className="overflow-y-auto overflow-x-hidden overscroll-contain scroll-smooth"
style={{
maxHeight: '320px',
WebkitOverflowScrolling: 'touch',
}}
role="listbox"
>
{options.length === 0 ? (
<li className="px-3 py-2 text-gray-500 text-sm text-center">
No options available
</li>
) : (
options.map((option) => {
const enabledIndex = enabledOptions.findIndex(opt => opt.value === option.value);
const isHighlighted = enabledIndex === highlightedIndex && !option.disabled;
return (
<li
key={option.value}
onClick={() => !option.disabled && handleSelect(option.value)}
className={`px-4 py-3 text-sm transition border-b border-gray-700/30 last:border-0 ${
option.disabled
? 'text-gray-600 cursor-not-allowed bg-gray-900/50'
: isHighlighted
? 'bg-blue-600 text-white cursor-pointer'
: value === option.value
? 'bg-gray-700 text-white cursor-pointer'
: 'text-gray-300 hover:bg-gray-700 cursor-pointer'
}`}
role="option"
aria-selected={value === option.value}
aria-disabled={option.disabled}
>
<div className="truncate" title={option.label}>
{option.label}
</div>
</li>
);
})
)}
</ul>
</div>,
document.body
);
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{/* Trigger Button */}
<div
onClick={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
className={`w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg transition flex items-center justify-between ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500'
}`}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className={value ? 'text-white' : 'text-gray-500'}>
{displayText || placeholder}
</span>
<div className="flex items-center gap-1">
{value && !required && !disabled && (
<button
onClick={handleClear}
className="p-0.5 hover:bg-gray-700 rounded transition"
type="button"
tabIndex={-1}
>
<X className="w-4 h-4 text-gray-400" />
</button>
)}
<ChevronDown
className={`w-4 h-4 text-gray-400 transition-transform ${
isOpen ? 'transform rotate-180' : ''
}`}
/>
</div>
</div>
{/* Required indicator (hidden input for form validation) */}
{required && (
<input
type="text"
value={value}
onChange={() => {}}
required
className="absolute opacity-0 pointer-events-none"
tabIndex={-1}
/>
)}
{/* Dropdown (rendered via portal) */}
{renderDropdown()}
</div>
);
};

View File

@ -1,42 +0,0 @@
/**
* 统一的 FormField 组件
* 用于包装表单元素,提供标签、帮助文本和错误信息
*/
import React from "react";
export interface FormFieldProps {
label?: string;
required?: boolean;
help?: string;
error?: string;
children: React.ReactNode;
className?: string;
}
export const FormField: React.FC<FormFieldProps> = ({
label,
required = false,
help,
error,
children,
className = "",
}) => {
return (
<div className={className}>
{label && (
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
{children}
{help && !error && (
<p className="mt-1 text-xs text-gray-500">{help}</p>
)}
{error && (
<p className="mt-1 text-xs text-red-400">{error}</p>
)}
</div>
);
};

View File

@ -1,63 +0,0 @@
/**
* 统一的 Input 组件
* 支持多种尺寸、状态和图标
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
export type InputSize = "sm" | "md" | "lg";
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
size?: InputSize;
error?: string;
icon?: LucideIcon;
iconPosition?: "left" | "right";
}
const sizeStyles: Record<InputSize, { input: string; icon: string }> = {
sm: { input: "px-3 py-1.5 text-sm", icon: "w-4 h-4" },
md: { input: "px-3 py-2 text-sm", icon: "w-4 h-4" },
lg: { input: "px-4 py-3 text-base", icon: "w-5 h-5" },
};
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ size = "md", error, icon: Icon, iconPosition = "left", className = "", ...props }, ref) => {
const baseStyles = "w-full bg-gray-800 border text-white rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 placeholder:text-gray-500";
const errorStyles = error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-700";
const iconPadding = Icon
? iconPosition === "left"
? "pl-10"
: "pr-10"
: "";
const combinedClassName = `
${baseStyles}
${sizeStyles[size].input}
${errorStyles}
${iconPadding}
${className}
`.trim();
return (
<div className="relative">
{Icon && iconPosition === "left" && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Icon className={sizeStyles[size].icon} />
</div>
)}
<input ref={ref} className={combinedClassName} {...props} />
{Icon && iconPosition === "right" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
<Icon className={sizeStyles[size].icon} />
</div>
)}
{error && (
<p className="mt-1 text-xs text-red-400">{error}</p>
)}
</div>
);
}
);
Input.displayName = "Input";

View File

@ -1,420 +0,0 @@
/**
* Schema Form Generator
* Dynamically generates form fields based on JSON Schema
* Used for Helm Chart values configuration
*/
import React from 'react';
import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
import { SearchableSelect } from './SearchableSelect';
import { DropdownSelect } from './DropdownSelect';
type JsonSchemaPrimitiveType = 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array';
export interface JsonSchema extends Record<string, unknown> {
type?: JsonSchemaPrimitiveType | string;
title?: string;
description?: string;
default?: unknown;
enum?: unknown[];
format?: string;
minimum?: number;
maximum?: number;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
}
interface SchemaFormGeneratorProps {
schema: JsonSchema;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
path?: string;
}
export const SchemaFormGenerator: React.FC<SchemaFormGeneratorProps> = ({
schema,
values,
onChange,
path = '',
}) => {
const properties = schema?.properties ?? {};
if (!schema || Object.keys(properties).length === 0) {
return (
<div className="flex items-center gap-2 p-3 bg-yellow-900/20 border border-yellow-700/50 rounded-lg text-yellow-300 text-sm">
<AlertCircle className="w-4 h-4" />
<span>No configuration schema available</span>
</div>
);
}
const handleFieldChange = (key: string, value: unknown) => {
const newValues = { ...values };
setNestedValue(newValues, key, value);
onChange(newValues);
};
const required = Array.isArray(schema.required) ? schema.required : [];
return (
<div className="space-y-4">
{Object.entries(properties as Record<string, JsonSchema>).map(([key, prop]) => (
<FormField
key={key}
name={key}
schema={prop}
value={values[key]}
onChange={(val) => handleFieldChange(key, val)}
required={required.includes(key)}
path={path ? `${path}.${key}` : key}
/>
))}
</div>
);
};
interface FormFieldProps {
name: string;
schema: JsonSchema;
value: unknown;
onChange: (value: unknown) => void;
required?: boolean;
path: string;
}
const FormField: React.FC<FormFieldProps> = ({
name,
schema,
value,
onChange,
required = false,
path,
}) => {
const [isExpanded, setIsExpanded] = React.useState(true);
// Get field label and description
const label = typeof schema.title === 'string' ? schema.title : formatFieldName(name);
const description = typeof schema.description === 'string' ? schema.description : undefined;
const defaultValue = schema.default;
// Determine field type
const type = (typeof schema.type === 'string' ? schema.type : 'string') as JsonSchemaPrimitiveType | string;
// Handle enum fields
if (Array.isArray(schema.enum)) {
const enumOptions = schema.enum;
const SEARCHABLE_THRESHOLD = 10; // Use searchable select when > 10 options
const enumOptionStrings = enumOptions.map((option) => String(option));
// Use searchable select for large option lists
if (enumOptions.length > SEARCHABLE_THRESHOLD) {
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
<span className="ml-2 text-xs text-gray-500">
({enumOptions.length} options - searchable)
</span>
</label>
<SearchableSelect
value={toStringValue(value ?? defaultValue)}
onChange={(val) => onChange(val)}
options={enumOptionStrings}
placeholder="Select or search..."
required={required}
/>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
</div>
);
}
// Use DropdownSelect for small option lists
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
<DropdownSelect
value={toStringValue(value ?? defaultValue)}
onChange={(val) => onChange(val)}
options={[
...(required ? [] : [{ value: '', label: 'Select...' }]),
...enumOptionStrings.map((option) => ({
value: option,
label: option,
})),
]}
placeholder="Select..."
required={required}
/>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
</div>
);
}
switch (type) {
case 'string':
// Check if it's a multiline string
const isMultiline =
schema.format === 'text' ||
(description && description.toLowerCase().includes('yaml'));
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
{isMultiline ? (
<textarea
value={toStringValue(value ?? defaultValue)}
onChange={(e) => onChange(e.target.value)}
placeholder={description || `Enter ${label.toLowerCase()}`}
rows={4}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
required={required}
/>
) : (
<input
type="text"
value={toStringValue(value ?? defaultValue)}
onChange={(e) => onChange(e.target.value)}
placeholder={description || `Enter ${label.toLowerCase()}`}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required={required}
/>
)}
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
{defaultValue !== undefined && (
<p className="text-xs text-gray-600 mt-1">Default: {String(defaultValue)}</p>
)}
</div>
);
case 'integer':
case 'number':
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
<input
type="number"
value={toNumberInputValue(value, defaultValue)}
onChange={(e) => {
const numValue = e.target.value === '' ? undefined : Number(e.target.value);
onChange(numValue);
}}
step={type === 'integer' ? '1' : 'any'}
min={typeof schema.minimum === 'number' ? schema.minimum : undefined}
max={typeof schema.maximum === 'number' ? schema.maximum : undefined}
placeholder={description}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required={required}
/>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
{(schema.minimum !== undefined || schema.maximum !== undefined) && (
<p className="text-xs text-gray-600 mt-1">
Range: {schema.minimum !== undefined ? schema.minimum : '∞'} - {schema.maximum !== undefined ? schema.maximum : '∞'}
</p>
)}
</div>
);
case 'boolean':
return (
<div className="flex items-start gap-3 p-3 bg-gray-800/50 rounded-lg">
<input
type="checkbox"
checked={toBooleanValue(value, defaultValue)}
onChange={(e) => onChange(e.target.checked)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<label className="text-sm font-medium text-gray-300">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
</div>
</div>
);
case 'object': {
const currentValue = isRecord(value) ? value : {};
const objectSchema = isRecord(schema) ? schema : {};
return (
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 bg-gray-800/50 hover:bg-gray-800 transition"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
<span className="font-medium text-gray-300">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</span>
</div>
<span className="text-xs text-gray-500">Object</span>
</button>
{isExpanded && (
<div className="p-4 space-y-4 bg-gray-900/30">
{description && (
<p className="text-xs text-gray-500 -mt-2 mb-2">{description}</p>
)}
{schema.properties ? (
<SchemaFormGenerator
schema={objectSchema as JsonSchema}
values={currentValue}
onChange={(updated) => onChange(updated)}
path={path}
/>
) : (
<p className="text-xs text-gray-500 italic">
No properties defined for this object
</p>
)}
</div>
)}
</div>
);
}
case 'array': {
const arrayText = Array.isArray(value)
? value.map((item) => String(item)).join('\n')
: toStringValue(value);
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
<span className="ml-2 text-xs text-gray-500">(Array)</span>
</label>
<textarea
value={arrayText}
onChange={(e) => {
const lines = e.target.value.split('\n').filter(line => line.trim());
onChange(lines);
}}
placeholder="Enter one item per line"
rows={4}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
required={required}
/>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
<p className="text-xs text-gray-600 mt-1">Enter one item per line</p>
</div>
);
}
default:
// Fallback for unknown types
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
<span className="ml-2 text-xs text-gray-500">({type})</span>
</label>
<input
type="text"
value={toStringValue(value ?? defaultValue)}
onChange={(e) => onChange(e.target.value)}
placeholder={description}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required={required}
/>
{description && (
<p className="text-xs text-gray-500 mt-1">{description}</p>
)}
</div>
);
}
};
// Helper function to set nested value in object
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown) {
const keys = path.split('.');
let current: Record<string, unknown> = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!isRecord(current[key])) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
current[keys[keys.length - 1]] = value;
}
// Helper function to format field name (camelCase -> Title Case)
function formatFieldName(name: string): string {
return name
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const toStringValue = (value: unknown, fallback = ''): string => {
if (value === undefined || value === null) {
return fallback;
}
return typeof value === 'string' ? value : String(value);
};
const toNumberInputValue = (value: unknown, fallback: unknown): number | '' => {
if (typeof value === 'number') {
return value;
}
if (value === '' || value === null || value === undefined) {
if (typeof fallback === 'number') {
return fallback;
}
return '';
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isNaN(parsed) ? '' : parsed;
}
if (typeof fallback === 'number') {
return fallback;
}
return '';
};
const toBooleanValue = (value: unknown, fallback: unknown): boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof fallback === 'boolean') {
return fallback;
}
return false;
};

View File

@ -1,306 +0,0 @@
/**
* Searchable Select Component
* Combo box with search/filter functionality for large option lists
*/
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search, X } from 'lucide-react';
interface SearchableSelectProps {
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
required?: boolean;
className?: string;
zIndex?: number; // Custom z-index for the dropdown
}
export const SearchableSelect: React.FC<SearchableSelectProps> = ({
value,
onChange,
options,
placeholder = 'Select...',
required = false,
className = '',
zIndex = 110, // Default z-index for dropdown
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Filter options based on search term
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchTerm.toLowerCase())
);
// Close dropdown on scroll (prevents it from covering modal header)
useEffect(() => {
if (isOpen && containerRef.current) {
const updatePosition = () => {
const rect = containerRef.current!.getBoundingClientRect();
// Always position below the trigger
setDropdownPosition({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
};
updatePosition();
// Close dropdown on scroll (prevents it from covering modal header)
const handleScroll = (e: Event) => {
// Only close if scroll is not from the dropdown itself
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
// Update position on resize, close on scroll
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', updatePosition);
};
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchTerm('');
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case 'Enter':
e.preventDefault();
if (filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSearchTerm('');
break;
}
};
// Scroll highlighted item into view
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [highlightedIndex, isOpen]);
// Reset highlighted index when filtered options change
useEffect(() => {
setHighlightedIndex(0);
}, [searchTerm]);
const handleSelect = (option: string) => {
onChange(option);
setIsOpen(false);
setSearchTerm('');
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setSearchTerm('');
inputRef.current?.focus();
};
const handleToggle = () => {
setIsOpen(!isOpen);
if (!isOpen) {
// Focus input when opening
setTimeout(() => inputRef.current?.focus(), 0);
}
};
// Get display text
const displayText = value || '';
// Render dropdown in portal (to avoid being clipped by Modal overflow)
const renderDropdown = () => {
if (!isOpen) return null;
return createPortal(
<div
ref={dropdownRef}
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-2xl flex flex-col"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
maxHeight: 'min(32rem, 80vh)',
zIndex: zIndex,
}}
role="dialog"
>
{/* Search Input */}
<div className="p-2 border-b border-gray-700 bg-gray-800 flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type to search..."
className="w-full pl-9 pr-3 py-2 bg-gray-900 border border-gray-700 text-white rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
</div>
</div>
{/* Options List */}
<ul
ref={listRef}
className="overflow-y-auto overflow-x-hidden overscroll-contain scroll-smooth flex-1"
style={{
maxHeight: '400px',
minHeight: '280px', // Display at least 5-6 options
WebkitOverflowScrolling: 'touch', // Enable momentum scrolling on iOS
}}
role="listbox"
>
{filteredOptions.length === 0 ? (
<li className="px-3 py-2 text-gray-500 text-sm text-center">
No results found
</li>
) : (
filteredOptions.map((option, index) => (
<li
key={option}
onClick={() => handleSelect(option)}
className={`px-4 py-3 text-sm cursor-pointer transition border-b border-gray-700/30 last:border-0 ${
index === highlightedIndex
? 'bg-blue-600 text-white'
: value === option
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700'
}`}
role="option"
aria-selected={value === option}
>
<div className="truncate" title={option}>
{option}
</div>
</li>
))
)}
</ul>
{/* Result Count - Always show for large lists */}
{(searchTerm || options.length > 20) && (
<div className="px-3 py-2 bg-gray-900/50 border-t border-gray-700 text-xs text-gray-500 flex items-center justify-between flex-shrink-0">
<span>
{searchTerm
? `${filteredOptions.length} of ${options.length} options`
: `${options.length} total options`
}
</span>
{!searchTerm && options.length > 20 && (
<span className="text-blue-400">💡 Type to search</span>
)}
</div>
)}
</div>,
document.body
);
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{/* Trigger Button/Input */}
<div
onClick={handleToggle}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg cursor-pointer hover:border-gray-600 transition flex items-center justify-between"
>
<span className={value ? 'text-white' : 'text-gray-500'}>
{displayText || placeholder}
</span>
<div className="flex items-center gap-1">
{value && (
<button
onClick={handleClear}
className="p-0.5 hover:bg-gray-700 rounded transition"
type="button"
>
<X className="w-4 h-4 text-gray-400" />
</button>
)}
<ChevronDown
className={`w-4 h-4 text-gray-400 transition-transform ${
isOpen ? 'transform rotate-180' : ''
}`}
/>
</div>
</div>
{/* Required indicator (hidden input for form validation) */}
{required && (
<input
type="text"
value={value}
onChange={() => {}}
required
className="absolute opacity-0 pointer-events-none"
tabIndex={-1}
/>
)}
{/* Dropdown (rendered via portal to avoid Modal overflow clipping) */}
{renderDropdown()}
</div>
);
};

View File

@ -1,43 +0,0 @@
/**
* 统一的 Textarea 组件
*/
import React from "react";
export type TextareaSize = "sm" | "md" | "lg";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
size?: TextareaSize;
error?: string;
}
const sizeStyles: Record<TextareaSize, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-3 py-2 text-sm",
lg: "px-4 py-3 text-base",
};
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ size = "md", error, className = "", ...props }, ref) => {
const baseStyles = "w-full bg-gray-800 border text-white rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 placeholder:text-gray-500 resize-y";
const errorStyles = error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-700";
const combinedClassName = `
${baseStyles}
${sizeStyles[size]}
${errorStyles}
${className}
`.trim();
return (
<div>
<textarea ref={ref} className={combinedClassName} {...props} />
{error && (
<p className="mt-1 text-xs text-red-400">{error}</p>
)}
</div>
);
}
);
Textarea.displayName = "Textarea";

View File

@ -1,17 +0,0 @@
/**
* Form Components - Form Controls and Related Components
* 表单组件 - 表单控件和相关组件
*/
// Basic form controls
export { Input, type InputProps } from "./Input";
export { Textarea, type TextareaProps } from "./Textarea";
export { Checkbox, type CheckboxProps } from "./Checkbox";
// Advanced form components
export { FormField, type FormFieldProps } from "./FormField";
export { DropdownSelect, type DropdownSelectOption } from "./DropdownSelect";
export { SearchableSelect } from "./SearchableSelect";
export { SchemaFormGenerator } from "./SchemaFormGenerator";

View File

@ -1,26 +0,0 @@
/**
* Shared Components - Unified Export
* 共享组件统一导出
*
* 新的组件架构:
* - ui/ 基础 UI 组件(按钮、卡片、徽章等)
* - form/ 表单组件(输入框、选择器、表单字段等)
* - layout/ 布局组件(模态框、选项卡、页面头部、应用外壳等)
* - feedback/ 反馈组件加载状态、错误状态、空状态、Toast 通知等)
* - data-display/ 数据展示组件(统计卡片等)
*/
// Export all UI components
export * from "./ui";
// Export all form components
export * from "./form";
// Export all layout components
export * from "./layout";
// Export all feedback components
export * from "./feedback";
// Export all data display components
export * from "./data-display";

View File

@ -1,29 +0,0 @@
import React from "react";
import { LayoutDashboard } from "lucide-react";
import SidebarLayout from "./SidebarLayout/SidebarLayout";
import TopNavLayout from "./TopNavLayout/TopNavLayout";
import type { NavItem } from "./SidebarLayout/SidebarNav";
export default function AppShell({
title = "应用管理",
icon = <LayoutDashboard className="w-5 h-5 text-brand-accent" />,
userName = "User",
navItems = [],
onSignOut,
children,
}: {
title?: string;
icon?: React.ReactNode;
userName?: string;
navItems?: NavItem[];
onSignOut?: () => void;
children: React.ReactNode;
}) {
return (
<SidebarLayout items={navItems}>
<TopNavLayout title={title} icon={icon} userName={userName} onSignOut={onSignOut}>
{children}
</TopNavLayout>
</SidebarLayout>
);
}

View File

@ -1,117 +0,0 @@
/**
* 统一的 Modal 组件
* 支持多种尺寸和样式
*/
import React, { useEffect } from "react";
import { X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export type ModalSize = "sm" | "md" | "lg" | "xl";
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
icon?: LucideIcon;
iconColor?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
size?: ModalSize;
closeOnOverlayClick?: boolean;
}
const sizeCls: Record<ModalSize, string> = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
const Modal: React.FC<ModalProps> = ({
open,
onClose,
title,
icon: Icon,
iconColor = "text-purple-400",
children,
footer,
size = "md",
closeOnOverlayClick = true,
}) => {
// Disable body scroll when modal is open
useEffect(() => {
if (open) {
// Prevent body scroll using overflow + padding to prevent layout shift
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`; // Prevent layout shift when scrollbar disappears
return () => {
// Restore body scroll
document.body.style.overflow = '';
document.body.style.paddingRight = '';
};
}
}, [open]);
if (!open) return null;
const handleOverlayClick = () => {
if (closeOnOverlayClick) {
onClose();
}
};
return (
<div className="fixed inset-0 z-[90] flex items-start sm:items-center justify-center overflow-y-auto p-4 sm:p-6">
{/* Overlay */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={handleOverlayClick}
aria-hidden="true"
/>
{/* Modal Content */}
<div
className={`relative w-full ${sizeCls[size]} my-8 bg-gray-900 text-white border border-gray-700 rounded-xl shadow-2xl`}
role="dialog"
aria-modal="true"
>
{/* Header */}
<div className="px-5 sm:px-6 py-4 flex items-center justify-between border-b border-gray-700 sticky top-0 bg-gray-900 rounded-t-xl z-10">
<div className="flex items-center gap-3">
{Icon && (
<div className={`p-2 rounded-lg ${iconColor.includes('purple') ? 'bg-purple-600/20' : iconColor.includes('blue') ? 'bg-blue-600/20' : iconColor.includes('green') ? 'bg-green-600/20' : 'bg-gray-600/20'}`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
)}
{title && (
<h2 className="font-semibold text-base sm:text-lg">{title}</h2>
)}
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Close modal"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="px-5 sm:px-6 py-4 max-h-[calc(100vh-12rem)] sm:max-h-[calc(100vh-10rem)] overflow-y-auto">
{children}
</div>
{/* Footer */}
{footer && (
<div className="px-5 sm:px-6 py-4 border-t border-gray-700 bg-gray-900/80 sticky bottom-0 rounded-b-xl flex items-center justify-end gap-3">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;

View File

@ -1,45 +0,0 @@
/**
* 统一的 PageHeader 组件
* 用于页面顶部标题和操作区域
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
export interface PageHeaderProps {
title: string;
description?: string;
icon?: LucideIcon;
iconColor?: string;
actions?: React.ReactNode;
children?: React.ReactNode;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
description,
icon: Icon,
iconColor = "text-purple-400",
actions,
children,
}) => {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{Icon && <Icon className={`w-8 h-8 ${iconColor}`} />}
<div>
<h1 className="text-2xl font-bold text-white">{title}</h1>
{description && (
<p className="text-sm text-gray-400 mt-1">{description}</p>
)}
</div>
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
{children}
</div>
);
};

View File

@ -1,38 +0,0 @@
import React, { useState } from "react";
import SidebarNav from "./SidebarNav";
import type { NavItem } from "./SidebarNav";
interface SidebarLayoutProps {
items?: NavItem[];
children: React.ReactNode;
}
export default function SidebarLayout({ items, children }: SidebarLayoutProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<div className="relative min-h-screen flex bg-dark text-primary overflow-hidden">
<div
className="pointer-events-none absolute inset-0 bg-app-gradient opacity-90"
aria-hidden="true"
/>
<SidebarNav
items={items ?? []}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<div className="relative z-10 flex flex-col flex-1">
{React.Children.map(children, (child) => {
// 将 toggleSidebar 函数传递给子组件
if (React.isValidElement(child)) {
return React.cloneElement(child, {
// @ts-ignore - 动态传递 props
onToggleSidebar: () => setIsSidebarOpen(!isSidebarOpen),
} as any);
}
return child;
})}
</div>
</div>
);
}

View File

@ -1,131 +0,0 @@
import React, { useState } from "react";
import { LayoutDashboard, ChevronDown, ChevronRight, X } from "lucide-react";
export interface NavItem {
key: string;
label: string;
icon?: React.ReactNode;
active?: boolean;
onClick?: () => void;
children?: NavItem[];
}
interface SidebarNavProps {
items?: NavItem[];
isOpen?: boolean;
onClose?: () => void;
}
export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onClose }: SidebarNavProps) {
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set(["configuration", "monitoring", "artifact", "cluster"]));
const toggleExpand = (key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const handleItemClick = (item: NavItem, hasChildren: boolean) => {
if (hasChildren) {
toggleExpand(item.key);
} else {
if (item.onClick) {
item.onClick();
}
// 移动端点击后关闭侧边栏
if (onClose) {
onClose();
}
}
};
const renderNavItem = (item: NavItem, level = 0) => {
const hasChildren = Boolean(item.children && item.children.length > 0);
const isExpanded = expandedKeys.has(item.key);
return (
<div key={item.key}>
<button
onClick={() => handleItemClick(item, hasChildren)}
className={`w-full text-left flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-colors duration-200 ${
item.active
? "bg-brand-accent/15 text-primary border border-brand-accent/40 shadow-glow"
: "text-secondary hover:text-primary hover:bg-dark-elevated/70"
}`}
style={{ paddingLeft: `${12 + level * 16}px` }}
>
{item.icon}
<span className="flex-1">{item.label}</span>
{hasChildren && (
isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)
)}
</button>
{hasChildren && isExpanded && (
<div className="mt-1 space-y-1">
{item.children!.map((child) => renderNavItem(child, level + 1))}
</div>
)}
</div>
);
};
return (
<>
{/* 移动端遮罩层 */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={onClose}
/>
)}
{/* 侧边栏 */}
<aside
className={`
fixed md:static inset-y-0 left-0 z-50 md:z-0
flex flex-col w-64 sm:w-72 md:w-60 xl:w-64 bg-dark-lighter/85 backdrop-blur-xl border-r border-dark-border/80 shadow-soft
transform transition-transform duration-300 ease-in-out
${isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-dark-border/70">
<div className="flex items-center gap-2">
<LayoutDashboard className="w-5 h-5 text-brand-accent" />
<span className="text-sm font-semibold text-secondary tracking-wide">Console</span>
</div>
{/* 移动端关闭按钮 */}
{onClose && (
<button
onClick={onClose}
className="md:hidden p-1.5 rounded-lg hover:bg-dark-elevated/70 text-secondary hover:text-primary transition"
aria-label="关闭菜单"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{items.map((item) => renderNavItem(item))}
</nav>
{/* Footer */}
<div className="p-3 text-xs text-muted border-t border-dark-border/70">
© {new Date().getFullYear()} OCDP
</div>
</aside>
</>
);
}

View File

@ -1,91 +0,0 @@
/**
* Tabs 组件
* 用于公有/专用/自建三级切换
* 支持响应式设计和移动端优化
*/
import React from "react";
import type { LucideIcon } from "lucide-react";
export interface TabItem {
id: string;
label: string;
icon?: LucideIcon;
badge?: string;
badgeColor?: string;
}
export interface TabsProps {
tabs: TabItem[];
activeTab: string;
onChange: (tabId: string) => void;
className?: string;
}
export const Tabs: React.FC<TabsProps> = ({
tabs,
activeTab,
onChange,
className = "",
}) => {
return (
<div className={`border-b border-gray-700 ${className}`}>
{/* 添加水平滚动支持(移动端优化) */}
<div className="flex gap-2 sm:gap-4 overflow-x-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`
flex items-center gap-1.5 sm:gap-2 px-3 py-2 sm:px-4 sm:py-3
text-xs sm:text-sm font-medium border-b-2 transition-all
whitespace-nowrap flex-shrink-0
${
isActive
? "border-blue-500 text-blue-400"
: "border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-600"
}
`}
>
{Icon && <Icon className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
<span>{tab.label}</span>
{tab.badge && (
<span
className={`
px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs rounded-full
${
tab.badgeColor ||
"bg-gray-700 text-gray-300 border border-gray-600"
}
`}
>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
interface TabPanelProps {
value: string;
activeTab: string;
children: React.ReactNode;
}
export const TabPanel: React.FC<TabPanelProps> = ({
value,
activeTab,
children,
}) => {
if (value !== activeTab) return null;
return <div className="mt-6">{children}</div>;
};

Some files were not shown because too many files have changed in this diff Show More