ocdp v1
This commit is contained in:
99
frontend/src/api/axios-mutator.ts
Normal file
99
frontend/src/api/axios-mutator.ts
Normal file
@ -0,0 +1,99 @@
|
||||
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'];
|
||||
}
|
||||
}
|
||||
439
frontend/src/api/generated-orval/api.schemas.ts
Normal file
439
frontend/src/api/generated-orval/api.schemas.ts
Normal file
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
474
frontend/src/api/generated-orval/api.ts
Normal file
474
frontend/src/api/generated-orval/api.ts
Normal file
@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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>>>
|
||||
197
frontend/src/api/index.ts
Normal file
197
frontend/src/api/index.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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;
|
||||
38
frontend/src/app/App.tsx
Normal file
38
frontend/src/app/App.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
130
frontend/src/app/constants/navigation.tsx
Normal file
130
frontend/src/app/constants/navigation.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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" /> };
|
||||
};
|
||||
|
||||
|
||||
11
frontend/src/app/index.ts
Normal file
11
frontend/src/app/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* App Module - Unified Export
|
||||
* 应用模块统一导出
|
||||
*/
|
||||
|
||||
export { default as App } from "./App";
|
||||
export * from "./providers";
|
||||
export * from "./routes/RouteGuard";
|
||||
export * from "./constants/navigation";
|
||||
|
||||
|
||||
23
frontend/src/app/providers/AuthContext.ts
Normal file
23
frontend/src/app/providers/AuthContext.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
103
frontend/src/app/providers/AuthProvider.tsx
Normal file
103
frontend/src/app/providers/AuthProvider.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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
|
||||
9
frontend/src/app/providers/index.ts
Normal file
9
frontend/src/app/providers/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Providers - Unified Export
|
||||
* 提供者统一导出
|
||||
*/
|
||||
|
||||
export { AuthProvider } from "./AuthProvider";
|
||||
export { useAuth } from "./useAuth";
|
||||
|
||||
|
||||
19
frontend/src/app/providers/useAuth.ts
Normal file
19
frontend/src/app/providers/useAuth.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
182
frontend/src/app/routes/AppRoutes.tsx
Normal file
182
frontend/src/app/routes/AppRoutes.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
39
frontend/src/app/routes/RouteGuard.tsx
Normal file
39
frontend/src/app/routes/RouteGuard.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 />;
|
||||
};
|
||||
|
||||
|
||||
272
frontend/src/components/ApiTest.tsx
Normal file
272
frontend/src/components/ApiTest.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/core/config/api-config.ts
Normal file
45
frontend/src/core/config/api-config.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
81
frontend/src/core/hooks/useUnifiedApi.ts
Normal file
81
frontend/src/core/hooks/useUnifiedApi.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
20
frontend/src/core/index.ts
Normal file
20
frontend/src/core/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
|
||||
90
frontend/src/core/types/index.ts
Normal file
90
frontend/src/core/types/index.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
16
frontend/src/features/artifact/index.ts
Normal file
16
frontend/src/features/artifact/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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';
|
||||
@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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 nested: 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;
|
||||
};
|
||||
12
frontend/src/features/artifact/instances/index.ts
Normal file
12
frontend/src/features/artifact/instances/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Service Instances Feature
|
||||
* 服务实例管理功能
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as InstancesManagementPage } from './pages/InstancesManagementPage';
|
||||
|
||||
// Export components
|
||||
export { InstanceCard } from './components/InstanceCard';
|
||||
export { ModifyModal } from './components/ModifyModal';
|
||||
|
||||
@ -0,0 +1,611 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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 # Example: # replicaCount: 3 # image: # repository: myapp # 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;
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
144
frontend/src/features/artifact/registries/components/TagCard.tsx
Normal file
144
frontend/src/features/artifact/registries/components/TagCard.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
13
frontend/src/features/artifact/registries/index.ts
Normal file
13
frontend/src/features/artifact/registries/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
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";
|
||||
};
|
||||
|
||||
11
frontend/src/features/auth/index.ts
Normal file
11
frontend/src/features/auth/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Authentication Feature
|
||||
* 用户认证功能 - 登录和注册
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as AuthPage } from './pages/AuthPage';
|
||||
|
||||
// Export types (if any)
|
||||
// export * from './types';
|
||||
|
||||
265
frontend/src/features/auth/pages/AuthPage.tsx
Normal file
265
frontend/src/features/auth/pages/AuthPage.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
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;
|
||||
@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/configuration/clusters/index.ts
Normal file
12
frontend/src/features/configuration/clusters/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Cluster Management Feature
|
||||
* 集群配置管理功能
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as ClusterConfigPage } from './pages/ClusterConfigPage';
|
||||
|
||||
// Export components
|
||||
export { ClusterForm } from './components/ClusterForm';
|
||||
export { ClusterList } from './components/ClusterList';
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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;
|
||||
15
frontend/src/features/configuration/index.ts
Normal file
15
frontend/src/features/configuration/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/configuration/registries/index.ts
Normal file
12
frontend/src/features/configuration/registries/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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;
|
||||
8
frontend/src/features/home/index.ts
Normal file
8
frontend/src/features/home/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Dashboard Feature
|
||||
* 首页仪表板
|
||||
*/
|
||||
|
||||
// Export pages
|
||||
export { default as HomePage } from './pages/HomePage';
|
||||
|
||||
247
frontend/src/features/home/pages/HomePage.tsx
Normal file
247
frontend/src/features/home/pages/HomePage.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
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;
|
||||
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
8
frontend/src/features/monitoring/clusters/index.ts
Normal file
8
frontend/src/features/monitoring/clusters/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Monitoring Feature Module
|
||||
* 监控功能模块
|
||||
*/
|
||||
|
||||
export { default as MonitoringClustersPage } from "./pages/MonitoringClustersPage";
|
||||
export { ClusterMonitorCard } from "./components/ClusterMonitorCard";
|
||||
|
||||
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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;
|
||||
9
frontend/src/features/monitoring/index.ts
Normal file
9
frontend/src/features/monitoring/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Monitoring Module
|
||||
* 监控模块 - 集群监控
|
||||
*/
|
||||
|
||||
// Clusters
|
||||
export { default as MonitoringClustersPage } from './clusters/pages/MonitoringClustersPage';
|
||||
export * from './clusters/components/ClusterMonitorCard';
|
||||
|
||||
9
frontend/src/index.css
Normal file
9
frontend/src/index.css
Normal file
@ -0,0 +1,9 @@
|
||||
@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; }
|
||||
24
frontend/src/main.tsx
Normal file
24
frontend/src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
// 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>
|
||||
);
|
||||
72
frontend/src/shared/components/data-display/StatsCard.tsx
Normal file
72
frontend/src/shared/components/data-display/StatsCard.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
8
frontend/src/shared/components/data-display/index.ts
Normal file
8
frontend/src/shared/components/data-display/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data Display Components - Components for Displaying Data
|
||||
* 数据展示组件 - 用于展示数据的组件
|
||||
*/
|
||||
|
||||
export { StatsCard, type StatsCardProps, type StatsCardVariant } from "./StatsCard";
|
||||
|
||||
|
||||
45
frontend/src/shared/components/feedback/EmptyState.tsx
Normal file
45
frontend/src/shared/components/feedback/EmptyState.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
33
frontend/src/shared/components/feedback/EmptyStateSimple.tsx
Normal file
33
frontend/src/shared/components/feedback/EmptyStateSimple.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 配置性界面的空状态组件(无引导按钮)
|
||||
* 仅提示用户当前没有配置数据
|
||||
*/
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 使用性界面的空状态组件(带引导按钮)
|
||||
* 当缺少配置时,引导用户前往对应的配置页面
|
||||
*/
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
38
frontend/src/shared/components/feedback/ErrorState.tsx
Normal file
38
frontend/src/shared/components/feedback/ErrorState.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
45
frontend/src/shared/components/feedback/LoadingState.tsx
Normal file
45
frontend/src/shared/components/feedback/LoadingState.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
28
frontend/src/shared/components/feedback/ToastContext.ts
Normal file
28
frontend/src/shared/components/feedback/ToastContext.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
270
frontend/src/shared/components/feedback/ToastProvider.tsx
Normal file
270
frontend/src/shared/components/feedback/ToastProvider.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
18
frontend/src/shared/components/feedback/index.ts
Normal file
18
frontend/src/shared/components/feedback/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
|
||||
14
frontend/src/shared/components/feedback/useToast.ts
Normal file
14
frontend/src/shared/components/feedback/useToast.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
46
frontend/src/shared/components/form/Checkbox.tsx
Normal file
46
frontend/src/shared/components/form/Checkbox.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 统一的 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";
|
||||
|
||||
288
frontend/src/shared/components/form/DropdownSelect.tsx
Normal file
288
frontend/src/shared/components/form/DropdownSelect.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
|
||||
42
frontend/src/shared/components/form/FormField.tsx
Normal file
42
frontend/src/shared/components/form/FormField.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
63
frontend/src/shared/components/form/Input.tsx
Normal file
63
frontend/src/shared/components/form/Input.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 统一的 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";
|
||||
|
||||
420
frontend/src/shared/components/form/SchemaFormGenerator.tsx
Normal file
420
frontend/src/shared/components/form/SchemaFormGenerator.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
306
frontend/src/shared/components/form/SearchableSelect.tsx
Normal file
306
frontend/src/shared/components/form/SearchableSelect.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
|
||||
43
frontend/src/shared/components/form/Textarea.tsx
Normal file
43
frontend/src/shared/components/form/Textarea.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 统一的 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";
|
||||
|
||||
17
frontend/src/shared/components/form/index.ts
Normal file
17
frontend/src/shared/components/form/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
|
||||
26
frontend/src/shared/components/index.ts
Normal file
26
frontend/src/shared/components/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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";
|
||||
29
frontend/src/shared/components/layout/AppShell.tsx
Normal file
29
frontend/src/shared/components/layout/AppShell.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
117
frontend/src/shared/components/layout/Modal.tsx
Normal file
117
frontend/src/shared/components/layout/Modal.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 统一的 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;
|
||||
45
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
45
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 统一的 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
frontend/src/shared/components/layout/Tabs.tsx
Normal file
91
frontend/src/shared/components/layout/Tabs.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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>;
|
||||
};
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { User, LogOut, Menu } from "lucide-react";
|
||||
|
||||
interface TopNavProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
userName?: string;
|
||||
onSignOut?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export default function TopNav({
|
||||
title,
|
||||
icon,
|
||||
userName,
|
||||
onSignOut,
|
||||
onToggleSidebar,
|
||||
}: TopNavProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-16 flex items-center justify-between px-4 sm:px-6 bg-dark-lighter/70 border-b border-dark-border/70 backdrop-blur-xl shadow-soft">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* 移动端菜单按钮 */}
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="md:hidden p-2 rounded-lg text-secondary hover:text-primary hover:bg-dark-elevated/70 transition"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{icon}
|
||||
<h1 className="text-base sm:text-lg font-semibold text-primary tracking-wide truncate">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-dark/70 border border-dark-border/60 shadow-soft">
|
||||
<User className="w-4 h-4 text-brand-accent" />
|
||||
<span className="text-sm text-secondary">{userName ?? "User"}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSignOut}
|
||||
className="p-2 rounded-lg border border-dark-border/70 text-secondary hover:text-primary hover:bg-brand-accent/10 hover:border-brand-accent/40 transition"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-brand-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import TopNav from "./TopNav";
|
||||
|
||||
interface TopNavLayoutProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
userName?: string;
|
||||
onSignOut?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TopNavLayout({
|
||||
title,
|
||||
icon,
|
||||
userName,
|
||||
onSignOut,
|
||||
onToggleSidebar,
|
||||
children,
|
||||
}: TopNavLayoutProps) {
|
||||
return (
|
||||
<div className="relative z-10 min-h-screen flex flex-col text-primary">
|
||||
<TopNav
|
||||
title={title}
|
||||
icon={icon}
|
||||
userName={userName}
|
||||
onSignOut={onSignOut}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
<main className="flex-1 w-full max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 py-6 space-y-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/shared/components/layout/index.ts
Normal file
20
frontend/src/shared/components/layout/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Layout Components - Layout and Structure Components
|
||||
* 布局组件 - 布局和结构组件
|
||||
*/
|
||||
|
||||
// Container components
|
||||
export { default as Modal, type ModalProps } from "./Modal";
|
||||
export { Tabs, TabPanel, type TabsProps, type TabItem } from "./Tabs";
|
||||
|
||||
// Page structure
|
||||
export { PageHeader, type PageHeaderProps } from "./PageHeader";
|
||||
export { default as AppShell } from "./AppShell";
|
||||
|
||||
// Layout systems
|
||||
export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout";
|
||||
export { default as SidebarNav } from "./SidebarLayout/SidebarNav";
|
||||
export { default as TopNavLayout } from "./TopNavLayout/TopNavLayout";
|
||||
export { default as TopNav } from "./TopNavLayout/TopNav";
|
||||
|
||||
|
||||
59
frontend/src/shared/components/ui/Badge.tsx
Normal file
59
frontend/src/shared/components/ui/Badge.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 统一的 Badge 组件
|
||||
* 用于状态标签、标记等
|
||||
*/
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type BadgeVariant = "default" | "success" | "warning" | "danger" | "info" | "purple" | "gray" | "blue" | "secondary";
|
||||
export type BadgeSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: "bg-blue-600/20 text-blue-300 border-blue-500/30",
|
||||
success: "bg-green-600/20 text-green-300 border-green-500/30",
|
||||
warning: "bg-yellow-600/20 text-yellow-300 border-yellow-500/30",
|
||||
danger: "bg-red-600/20 text-red-300 border-red-500/30",
|
||||
info: "bg-blue-600/20 text-blue-300 border-blue-500/30",
|
||||
purple: "bg-purple-600/20 text-purple-300 border-purple-500/30",
|
||||
gray: "bg-gray-600/20 text-gray-300 border-gray-500/30",
|
||||
blue: "bg-blue-600/20 text-blue-300 border-blue-500/30",
|
||||
secondary: "bg-gray-700/30 text-gray-200 border-gray-500/40",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<BadgeSize, { badge: string; icon: string }> = {
|
||||
sm: { badge: "px-2 py-0.5 text-xs", icon: "w-3 h-3" },
|
||||
md: { badge: "px-3 py-1 text-sm", icon: "w-4 h-4" },
|
||||
lg: { badge: "px-4 py-1.5 text-base", icon: "w-5 h-5" },
|
||||
};
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
icon: Icon,
|
||||
className = "",
|
||||
}) => {
|
||||
const baseStyles = "inline-flex items-center gap-1.5 font-medium rounded-full border";
|
||||
|
||||
const combinedClassName = `
|
||||
${baseStyles}
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size].badge}
|
||||
${className}
|
||||
`.trim();
|
||||
|
||||
return (
|
||||
<span className={combinedClassName}>
|
||||
{Icon && <Icon className={sizeStyles[size].icon} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
90
frontend/src/shared/components/ui/Button.tsx
Normal file
90
frontend/src/shared/components/ui/Button.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 统一的 Button 组件
|
||||
* 支持多种变体、尺寸和状态
|
||||
*/
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "ghost";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: LucideIcon;
|
||||
iconPosition?: "left" | "right";
|
||||
loading?: boolean;
|
||||
spinIcon?: boolean; // When true and loading, spin the icon instead of showing a loader
|
||||
fullWidth?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: "bg-purple-600 hover:bg-purple-700 text-white border-purple-600",
|
||||
secondary: "bg-gray-700 hover:bg-gray-600 text-white border-gray-700",
|
||||
success: "bg-green-600 hover:bg-green-700 text-white border-green-600",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white border-red-600",
|
||||
warning: "bg-yellow-600 hover:bg-yellow-700 text-white border-yellow-600",
|
||||
ghost: "bg-transparent hover:bg-gray-800 text-gray-300 border-gray-600",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, { button: string; icon: string }> = {
|
||||
sm: { button: "px-3 py-1.5 text-sm", icon: "w-3.5 h-3.5" },
|
||||
md: { button: "px-4 py-2 text-sm", icon: "w-4 h-4" },
|
||||
lg: { button: "px-6 py-3 text-base", icon: "w-5 h-5" },
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
icon: Icon,
|
||||
iconPosition = "left",
|
||||
loading = false,
|
||||
spinIcon = false,
|
||||
fullWidth = false,
|
||||
disabled = false,
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
const baseStyles = "inline-flex items-center justify-center gap-2 font-medium rounded-lg border transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||||
const disabledStyles = "disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
|
||||
const combinedClassName = `
|
||||
${baseStyles}
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size].button}
|
||||
${disabledStyles}
|
||||
${widthStyles}
|
||||
${className}
|
||||
`.trim();
|
||||
|
||||
const iconClassName = sizeStyles[size].icon;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (loading && !spinIcon) {
|
||||
// Show generic spinner when loading and spinIcon is false
|
||||
return (
|
||||
<div className={`animate-spin rounded-full border-2 border-current border-t-transparent ${iconClassName}`} />
|
||||
);
|
||||
}
|
||||
if (Icon) {
|
||||
// Spin the icon if loading and spinIcon is true
|
||||
const spinClass = loading && spinIcon ? "animate-spin" : "";
|
||||
return <Icon className={`${iconClassName} ${spinClass}`} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={combinedClassName} disabled={isDisabled} {...props}>
|
||||
{iconPosition === "left" && renderIcon()}
|
||||
{children}
|
||||
{iconPosition === "right" && !loading && renderIcon()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
46
frontend/src/shared/components/ui/Card.tsx
Normal file
46
frontend/src/shared/components/ui/Card.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 统一的 Card 组件
|
||||
* 用于内容容器
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
export interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = "", hover = false, onClick }) => {
|
||||
const baseStyles = "bg-gray-800/50 border border-gray-700 rounded-lg overflow-hidden";
|
||||
const hoverStyles = hover ? "hover:border-gray-600 transition-colors cursor-pointer" : "";
|
||||
const clickStyles = onClick ? "cursor-pointer" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseStyles} ${hoverStyles} ${clickStyles} ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = "" }) => (
|
||||
<div className={`px-6 py-4 border-b border-gray-700 ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardBody: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = "" }) => (
|
||||
<div className={`px-6 py-4 ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = "" }) => (
|
||||
<div className={`px-6 py-4 border-t border-gray-700 bg-gray-900/50 ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
10
frontend/src/shared/components/ui/index.ts
Normal file
10
frontend/src/shared/components/ui/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* UI Components - Basic UI Elements
|
||||
* 基础 UI 组件 - 不可再分的基础元素
|
||||
*/
|
||||
|
||||
export { Badge, type BadgeProps, type BadgeVariant, type BadgeSize } from "./Badge";
|
||||
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from "./Button";
|
||||
export { Card, CardHeader, CardBody, CardFooter, type CardProps } from "./Card";
|
||||
|
||||
|
||||
7
frontend/src/shared/constants/index.ts
Normal file
7
frontend/src/shared/constants/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared Constants
|
||||
* Export all constants from a single entry point
|
||||
*/
|
||||
|
||||
export * from './ui-text';
|
||||
|
||||
260
frontend/src/shared/constants/ui-text.ts
Normal file
260
frontend/src/shared/constants/ui-text.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Unified UI Text Constants
|
||||
* 统一的 UI 文本常量
|
||||
*
|
||||
* 目的:确保整个前端应用的文本具有严格的一致性
|
||||
* Purpose: Ensure strict consistency of text across the entire frontend application
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Button Text
|
||||
// ============================================
|
||||
export const ButtonText = {
|
||||
// Common Actions
|
||||
SAVE: "Save",
|
||||
CANCEL: "Cancel",
|
||||
DELETE: "Delete",
|
||||
EDIT: "Edit",
|
||||
ADD: "Add",
|
||||
CREATE: "Create",
|
||||
UPDATE: "Update",
|
||||
CONFIRM: "Confirm",
|
||||
CLOSE: "Close",
|
||||
|
||||
// Data Operations
|
||||
REFRESH: "Refresh",
|
||||
RELOAD: "Reload",
|
||||
RETRY: "Retry",
|
||||
LOAD_MORE: "Load More",
|
||||
|
||||
// Navigation
|
||||
BACK: "Back",
|
||||
NEXT: "Next",
|
||||
GO_TO: "Go to",
|
||||
VIEW_DETAILS: "View Details",
|
||||
|
||||
// Instance/Deployment
|
||||
DEPLOY: "Deploy",
|
||||
UPGRADE: "Upgrade",
|
||||
ROLLBACK: "Rollback",
|
||||
START: "Start",
|
||||
STOP: "Stop",
|
||||
RESTART: "Restart",
|
||||
|
||||
// Test & Verify
|
||||
TEST: "Test",
|
||||
TEST_CONNECTION: "Test Connection",
|
||||
VERIFY: "Verify",
|
||||
|
||||
// File & Copy
|
||||
COPY: "Copy",
|
||||
DOWNLOAD: "Download",
|
||||
UPLOAD: "Upload",
|
||||
EXPORT: "Export",
|
||||
IMPORT: "Import",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Loading State Text
|
||||
// ============================================
|
||||
export const LoadingText = {
|
||||
// Generic
|
||||
LOADING: "Loading...",
|
||||
LOADING_DATA: "Loading data...",
|
||||
PLEASE_WAIT: "Please wait...",
|
||||
|
||||
// Specific Resources
|
||||
LOADING_CLUSTERS: "Loading clusters...",
|
||||
LOADING_REGISTRIES: "Loading registries...",
|
||||
LOADING_INSTANCES: "Loading instances...",
|
||||
LOADING_REPOSITORIES: "Loading repositories...",
|
||||
LOADING_TAGS: "Loading tags...",
|
||||
LOADING_HISTORY: "Loading history...",
|
||||
|
||||
// Operations
|
||||
SAVING: "Saving...",
|
||||
DELETING: "Deleting...",
|
||||
DEPLOYING: "Deploying...",
|
||||
UPGRADING: "Upgrading...",
|
||||
TESTING: "Testing connection...",
|
||||
REFRESHING: "Refreshing...",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Empty State Text
|
||||
// ============================================
|
||||
export const EmptyText = {
|
||||
// Generic
|
||||
NO_DATA: "No data available",
|
||||
NO_RESULTS: "No results found",
|
||||
NO_ITEMS: "No items to display",
|
||||
|
||||
// Specific Resources
|
||||
NO_CLUSTERS: "No clusters found",
|
||||
NO_REGISTRIES: "No registries found",
|
||||
NO_INSTANCES: "No instances found",
|
||||
NO_REPOSITORIES: "No repositories found",
|
||||
NO_TAGS: "No tags found",
|
||||
NO_HISTORY: "No deployment history",
|
||||
|
||||
// Empty State Descriptions
|
||||
NO_CLUSTERS_DESC: "You haven't added any Kubernetes clusters yet. Add a cluster to get started.",
|
||||
NO_REGISTRIES_DESC: "You haven't added any OCI registries yet. Add a registry to get started.",
|
||||
NO_INSTANCES_DESC: "No application instances are currently deployed.",
|
||||
NO_REPOSITORIES_DESC: "This registry doesn't contain any repositories yet.",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Error State Text
|
||||
// ============================================
|
||||
export const ErrorText = {
|
||||
// Generic
|
||||
ERROR_OCCURRED: "An error occurred",
|
||||
SOMETHING_WENT_WRONG: "Something went wrong",
|
||||
FAILED_TO_LOAD: "Failed to load data",
|
||||
|
||||
// Action Prompts
|
||||
TRY_AGAIN: "Please try again",
|
||||
RETRY_ACTION: "Click Retry to try again",
|
||||
REFRESH_PAGE: "Please refresh the page",
|
||||
CHECK_CONNECTION: "Please check your connection",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Confirmation Text
|
||||
// ============================================
|
||||
export const ConfirmText = {
|
||||
// Delete Confirmations
|
||||
DELETE_CLUSTER: (name: string) => `Are you sure you want to delete cluster "${name}"? This action cannot be undone.`,
|
||||
DELETE_REGISTRY: (name: string) => `Are you sure you want to delete registry "${name}"? This action cannot be undone.`,
|
||||
DELETE_INSTANCE: (name: string) => `Are you sure you want to delete instance "${name}"? This action cannot be undone.`,
|
||||
|
||||
// Rollback Confirmation
|
||||
ROLLBACK_INSTANCE: (revision: number) => `Are you sure you want to rollback to revision ${revision}?`,
|
||||
|
||||
// Generic Confirmations
|
||||
CONFIRM_ACTION: "Are you sure you want to proceed?",
|
||||
CANNOT_UNDO: "This action cannot be undone.",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Status Text
|
||||
// ============================================
|
||||
export const StatusText = {
|
||||
// Connection Status
|
||||
CONNECTED: "Connected",
|
||||
DISCONNECTED: "Disconnected",
|
||||
CONNECTING: "Connecting...",
|
||||
|
||||
// Deployment Status
|
||||
DEPLOYED: "Deployed",
|
||||
DEPLOYING: "Deploying",
|
||||
FAILED: "Failed",
|
||||
PENDING: "Pending",
|
||||
RUNNING: "Running",
|
||||
STOPPED: "Stopped",
|
||||
|
||||
// Health Status
|
||||
HEALTHY: "Healthy",
|
||||
UNHEALTHY: "Unhealthy",
|
||||
WARNING: "Warning",
|
||||
UNKNOWN: "Unknown",
|
||||
|
||||
// Operation Status
|
||||
SUCCESS: "Success",
|
||||
IN_PROGRESS: "In Progress",
|
||||
COMPLETED: "Completed",
|
||||
CANCELLED: "Cancelled",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Label Text
|
||||
// ============================================
|
||||
export const LabelText = {
|
||||
// Form Fields
|
||||
NAME: "Name",
|
||||
DESCRIPTION: "Description",
|
||||
URL: "URL",
|
||||
USERNAME: "Username",
|
||||
PASSWORD: "Password",
|
||||
TOKEN: "Token",
|
||||
NAMESPACE: "Namespace",
|
||||
VERSION: "Version",
|
||||
VALUES: "Values",
|
||||
|
||||
// Resource Labels
|
||||
CLUSTER: "Cluster",
|
||||
CLUSTERS: "Clusters",
|
||||
REGISTRY: "Registry",
|
||||
REGISTRIES: "Registries",
|
||||
INSTANCE: "Instance",
|
||||
INSTANCES: "Instances",
|
||||
REPOSITORY: "Repository",
|
||||
REPOSITORIES: "Repositories",
|
||||
TAG: "Tag",
|
||||
TAGS: "Tags",
|
||||
|
||||
// Info Labels
|
||||
CREATED_AT: "Created At",
|
||||
UPDATED_AT: "Updated At",
|
||||
LAST_DEPLOYED: "Last Deployed",
|
||||
REVISION: "Revision",
|
||||
STATUS: "Status",
|
||||
TYPE: "Type",
|
||||
SIZE: "Size",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Placeholder Text
|
||||
// ============================================
|
||||
export const PlaceholderText = {
|
||||
// Search & Filter
|
||||
SEARCH: "Search...",
|
||||
FILTER: "Filter...",
|
||||
SELECT: "Select...",
|
||||
|
||||
// Form Inputs
|
||||
ENTER_NAME: "Enter name",
|
||||
ENTER_URL: "Enter URL",
|
||||
ENTER_DESCRIPTION: "Enter description",
|
||||
ENTER_USERNAME: "Enter username",
|
||||
ENTER_PASSWORD: "Enter password",
|
||||
SELECT_CLUSTER: "Select a cluster",
|
||||
SELECT_NAMESPACE: "Select namespace",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Info & Help Text
|
||||
// ============================================
|
||||
export const InfoText = {
|
||||
// Tips
|
||||
TIP: "Tip",
|
||||
TIPS: "Tips",
|
||||
USAGE_TIPS: "Usage Tips",
|
||||
|
||||
// Warnings
|
||||
WARNING: "Warning",
|
||||
CAUTION: "Caution",
|
||||
IMPORTANT: "Important",
|
||||
|
||||
// Notes
|
||||
NOTE: "Note",
|
||||
NOTES: "Notes",
|
||||
INFO: "Information",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Export All
|
||||
// ============================================
|
||||
export const UIText = {
|
||||
Button: ButtonText,
|
||||
Loading: LoadingText,
|
||||
Empty: EmptyText,
|
||||
Error: ErrorText,
|
||||
Confirm: ConfirmText,
|
||||
Status: StatusText,
|
||||
Label: LabelText,
|
||||
Placeholder: PlaceholderText,
|
||||
Info: InfoText,
|
||||
} as const;
|
||||
|
||||
10
frontend/src/shared/hooks/index.ts
Normal file
10
frontend/src/shared/hooks/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared Hooks - Unified Export
|
||||
* 共享 Hooks 统一导出
|
||||
*
|
||||
* 通用的自定义 React Hooks
|
||||
*/
|
||||
|
||||
export { useMediaQuery } from "./useMediaQuery";
|
||||
|
||||
|
||||
88
frontend/src/shared/hooks/useMediaQuery.ts
Normal file
88
frontend/src/shared/hooks/useMediaQuery.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 响应式媒体查询 Hooks
|
||||
* 用于检测屏幕尺寸和响应式断点
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 通用媒体查询 Hook
|
||||
* @param query - CSS 媒体查询字符串
|
||||
* @returns 是否匹配查询条件
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
|
||||
// 初始化状态
|
||||
setMatches(media.matches);
|
||||
|
||||
// 监听变化
|
||||
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
|
||||
// 兼容旧版浏览器
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener('change', listener);
|
||||
} else {
|
||||
// @ts-ignore - 兼容旧版 API
|
||||
media.addListener(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (media.removeEventListener) {
|
||||
media.removeEventListener('change', listener);
|
||||
} else {
|
||||
// @ts-ignore - 兼容旧版 API
|
||||
media.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为移动端(< 768px)
|
||||
*/
|
||||
export const useIsMobile = () => useMediaQuery('(max-width: 767px)');
|
||||
|
||||
/**
|
||||
* 检测是否为平板(768px ~ 1023px)
|
||||
*/
|
||||
export const useIsTablet = () => useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
|
||||
|
||||
/**
|
||||
* 检测是否为桌面端(>= 1024px)
|
||||
*/
|
||||
export const useIsDesktop = () => useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
/**
|
||||
* 检测是否为小屏幕(< 640px)
|
||||
*/
|
||||
export const useIsSmallScreen = () => useMediaQuery('(max-width: 639px)');
|
||||
|
||||
/**
|
||||
* 检测是否为大屏幕(>= 1280px)
|
||||
*/
|
||||
export const useIsLargeScreen = () => useMediaQuery('(min-width: 1280px)');
|
||||
|
||||
/**
|
||||
* 获取当前断点名称
|
||||
*/
|
||||
export const useBreakpoint = (): 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' => {
|
||||
const is2xl = useMediaQuery('(min-width: 1536px)');
|
||||
const isXl = useMediaQuery('(min-width: 1280px)');
|
||||
const isLg = useMediaQuery('(min-width: 1024px)');
|
||||
const isMd = useMediaQuery('(min-width: 768px)');
|
||||
const isSm = useMediaQuery('(min-width: 640px)');
|
||||
|
||||
if (is2xl) return '2xl';
|
||||
if (isXl) return 'xl';
|
||||
if (isLg) return 'lg';
|
||||
if (isMd) return 'md';
|
||||
if (isSm) return 'sm';
|
||||
return 'xs';
|
||||
};
|
||||
|
||||
|
||||
31
frontend/src/shared/index.ts
Normal file
31
frontend/src/shared/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared Module - Unified Export
|
||||
* 共享模块统一导出
|
||||
*
|
||||
* 包含所有可复用的共享资源:
|
||||
* - components/ UI 组件库
|
||||
* - hooks/ 自定义 Hooks
|
||||
* - utils/ 工具函数
|
||||
* - services/ 服务层
|
||||
* - styles/ 样式和设计令牌
|
||||
*/
|
||||
|
||||
// Export all shared components
|
||||
export * from "./components";
|
||||
|
||||
// Export all shared hooks
|
||||
export * from "./hooks";
|
||||
|
||||
// Export all shared utils
|
||||
export * from "./utils";
|
||||
|
||||
// Export all shared services
|
||||
export * from "./services";
|
||||
|
||||
// Export all shared constants
|
||||
export * from "./constants";
|
||||
|
||||
// Export design tokens
|
||||
export * from "./styles/design-tokens";
|
||||
|
||||
|
||||
173
frontend/src/shared/services/artifact-cache.ts
Normal file
173
frontend/src/shared/services/artifact-cache.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Global Cache Service
|
||||
* 全局缓存服务 - 支持多种数据类型和不同的缓存策略
|
||||
*
|
||||
* 缓存策略:
|
||||
* - registries: 30分钟 (registry 配置变化不频繁)
|
||||
* - repositories: 10分钟 (repository 列表相对稳定)
|
||||
* - tags: 5分钟 (tags 可能会更新,但也不是很频繁)
|
||||
* - clusters: 30分钟 (cluster 配置变化不频繁)
|
||||
* - instances: 2分钟 (instance 状态需要相对实时)
|
||||
*/
|
||||
|
||||
import type { ArtifactListItem } from "@/api";
|
||||
|
||||
interface CacheEntry<T = any> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
type CacheType = 'registries' | 'repositories' | 'tags' | 'clusters' | 'instances';
|
||||
|
||||
// 不同数据类型的缓存时长配置(毫秒)
|
||||
const CACHE_TTL: Record<CacheType, number> = {
|
||||
registries: 30 * 60 * 1000, // 30分钟 - registry配置很少变化
|
||||
repositories: 10 * 60 * 1000, // 10分钟 - repository列表相对稳定
|
||||
tags: 5 * 60 * 1000, // 5分钟 - tags可能会更新
|
||||
clusters: 30 * 60 * 1000, // 30分钟 - cluster配置很少变化
|
||||
instances: 2 * 60 * 1000, // 2分钟 - instance状态需要相对实时
|
||||
};
|
||||
|
||||
class GlobalCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
*/
|
||||
private getKey(type: CacheType, ...identifiers: string[]): string {
|
||||
return `${type}:${identifiers.join(':')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存数据
|
||||
*/
|
||||
get<T = any>(type: CacheType, ...identifiers: string[]): T | null {
|
||||
const key = this.getKey(type, ...identifiers);
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
const ttl = CACHE_TTL[type];
|
||||
if (Date.now() - entry.timestamp > ttl) {
|
||||
console.log(`[GlobalCache] 缓存过期: ${key} (TTL: ${ttl}ms)`);
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[GlobalCache] 命中缓存: ${key} (age: ${Math.floor((Date.now() - entry.timestamp) / 1000)}s)`);
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存数据
|
||||
*/
|
||||
set<T = any>(type: CacheType, data: T, ...identifiers: string[]): void {
|
||||
const key = this.getKey(type, ...identifiers);
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
loading: false,
|
||||
});
|
||||
console.log(`[GlobalCache] 已缓存: ${key} (TTL: ${CACHE_TTL[type]}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为加载中
|
||||
*/
|
||||
setLoading(type: CacheType, ...identifiers: string[]): void {
|
||||
const key = this.getKey(type, ...identifiers);
|
||||
const existing = this.cache.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.loading = true;
|
||||
} else {
|
||||
this.cache.set(key, {
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在加载
|
||||
*/
|
||||
isLoading(type: CacheType, ...identifiers: string[]): boolean {
|
||||
const key = this.getKey(type, ...identifiers);
|
||||
const entry = this.cache.get(key);
|
||||
return entry?.loading || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除特定缓存
|
||||
*/
|
||||
clear(type: CacheType, ...identifiers: string[]): void {
|
||||
const key = this.getKey(type, ...identifiers);
|
||||
this.cache.delete(key);
|
||||
console.log(`[GlobalCache] 已清除: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个类型的所有缓存
|
||||
*/
|
||||
clearType(type: CacheType): void {
|
||||
const prefix = `${type}:`;
|
||||
let count = 0;
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.cache.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`[GlobalCache] 已清除 ${count} 个 ${type} 缓存`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clearAll(): void {
|
||||
const size = this.cache.size;
|
||||
this.cache.clear();
|
||||
console.log(`[GlobalCache] 已清除所有缓存 (${size} 个条目)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
getStats(): { type: string; count: number; ttl: string }[] {
|
||||
const stats = new Map<CacheType, number>();
|
||||
|
||||
for (const key of this.cache.keys()) {
|
||||
const type = key.split(':')[0] as CacheType;
|
||||
stats.set(type, (stats.get(type) || 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(stats.entries()).map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
ttl: `${CACHE_TTL[type] / 1000}s`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
export const globalCache = new GlobalCache();
|
||||
|
||||
// 兼容旧的 artifactCache
|
||||
export const artifactCache = {
|
||||
get: (registryId: string, repository: string) =>
|
||||
globalCache.get<ArtifactListItem[]>('tags', registryId, repository),
|
||||
set: (registryId: string, repository: string, data: ArtifactListItem[]) =>
|
||||
globalCache.set('tags', data, registryId, repository),
|
||||
setLoading: (registryId: string, repository: string) =>
|
||||
globalCache.setLoading('tags', registryId, repository),
|
||||
isLoading: (registryId: string, repository: string) =>
|
||||
globalCache.isLoading('tags', registryId, repository),
|
||||
clear: (registryId: string, repository: string) =>
|
||||
globalCache.clear('tags', registryId, repository),
|
||||
clearAll: () => globalCache.clearType('tags'),
|
||||
};
|
||||
10
frontend/src/shared/services/index.ts
Normal file
10
frontend/src/shared/services/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared Services - Unified Export
|
||||
* 共享服务统一导出
|
||||
*
|
||||
* 应用级别的服务层
|
||||
*/
|
||||
|
||||
export * from "./artifact-cache";
|
||||
|
||||
|
||||
185
frontend/src/shared/styles/design-tokens.ts
Normal file
185
frontend/src/shared/styles/design-tokens.ts
Normal file
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 设计 Token - 统一的设计规范常量
|
||||
* 用于保证全局 UI 组件的一致性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 响应式断点(像素值)
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Z-Index 层级(避免混乱的 z-index)
|
||||
*/
|
||||
export const Z_INDEX = {
|
||||
base: 0,
|
||||
dropdown: 50,
|
||||
sticky: 60,
|
||||
overlay: 80,
|
||||
modal: 90,
|
||||
toast: 100,
|
||||
tooltip: 110,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 间距规范(Tailwind class)
|
||||
*/
|
||||
export const SPACING = {
|
||||
mobile: {
|
||||
padding: {
|
||||
page: 'p-4',
|
||||
section: 'p-3',
|
||||
card: 'p-4',
|
||||
input: 'p-2.5',
|
||||
},
|
||||
gap: {
|
||||
small: 'gap-2',
|
||||
medium: 'gap-3',
|
||||
large: 'gap-4',
|
||||
},
|
||||
margin: {
|
||||
small: 'm-2',
|
||||
medium: 'm-3',
|
||||
large: 'm-4',
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
padding: {
|
||||
page: 'p-6',
|
||||
section: 'p-5',
|
||||
card: 'p-6',
|
||||
input: 'p-3',
|
||||
},
|
||||
gap: {
|
||||
small: 'gap-3',
|
||||
medium: 'gap-4',
|
||||
large: 'gap-6',
|
||||
},
|
||||
margin: {
|
||||
small: 'm-3',
|
||||
medium: 'm-4',
|
||||
large: 'm-6',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 字体大小规范(Tailwind class)
|
||||
*/
|
||||
export const TYPOGRAPHY = {
|
||||
mobile: {
|
||||
h1: 'text-xl',
|
||||
h2: 'text-lg',
|
||||
h3: 'text-base',
|
||||
body: 'text-sm',
|
||||
small: 'text-xs',
|
||||
},
|
||||
desktop: {
|
||||
h1: 'text-3xl',
|
||||
h2: 'text-2xl',
|
||||
h3: 'text-xl',
|
||||
body: 'text-base',
|
||||
small: 'text-sm',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 颜色主题(Tailwind class)
|
||||
*/
|
||||
export const COLORS = {
|
||||
brand: {
|
||||
primary: 'text-brand-blue',
|
||||
primaryBg: 'bg-brand-blue',
|
||||
primaryBorder: 'border-brand-blue',
|
||||
},
|
||||
status: {
|
||||
success: 'text-emerald-400',
|
||||
error: 'text-red-400',
|
||||
warning: 'text-amber-400',
|
||||
info: 'text-sky-400',
|
||||
},
|
||||
neutral: {
|
||||
text: 'text-gray-300',
|
||||
textLight: 'text-gray-400',
|
||||
textLighter: 'text-gray-500',
|
||||
bg: 'bg-gray-800',
|
||||
bgLight: 'bg-gray-700',
|
||||
bgDark: 'bg-gray-900',
|
||||
border: 'border-gray-700',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 圆角规范
|
||||
*/
|
||||
export const RADIUS = {
|
||||
small: 'rounded',
|
||||
medium: 'rounded-lg',
|
||||
large: 'rounded-xl',
|
||||
full: 'rounded-full',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 阴影规范
|
||||
*/
|
||||
export const SHADOW = {
|
||||
small: 'shadow-sm',
|
||||
medium: 'shadow-md',
|
||||
large: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
'2xl': 'shadow-2xl',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 过渡动画时长
|
||||
*/
|
||||
export const TRANSITION = {
|
||||
fast: 'duration-150',
|
||||
normal: 'duration-200',
|
||||
slow: 'duration-300',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 组件尺寸
|
||||
*/
|
||||
export const SIZES = {
|
||||
button: {
|
||||
small: 'px-3 py-1.5 text-sm',
|
||||
medium: 'px-4 py-2 text-base',
|
||||
large: 'px-6 py-3 text-lg',
|
||||
},
|
||||
input: {
|
||||
small: 'px-2.5 py-1.5 text-sm',
|
||||
medium: 'px-3 py-2 text-base',
|
||||
large: 'px-4 py-3 text-lg',
|
||||
},
|
||||
icon: {
|
||||
small: 'w-4 h-4',
|
||||
medium: 'w-5 h-5',
|
||||
large: 'w-6 h-6',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 触摸目标最小尺寸(移动端可访问性)
|
||||
*/
|
||||
export const TOUCH_TARGET_MIN_SIZE = 44; // 像素
|
||||
|
||||
/**
|
||||
* 常用媒体查询字符串
|
||||
*/
|
||||
export const MEDIA_QUERIES = {
|
||||
mobile: `(max-width: ${BREAKPOINTS.md - 1}px)`,
|
||||
tablet: `(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`,
|
||||
desktop: `(min-width: ${BREAKPOINTS.lg}px)`,
|
||||
smallScreen: `(max-width: ${BREAKPOINTS.sm - 1}px)`,
|
||||
largeScreen: `(min-width: ${BREAKPOINTS.xl}px)`,
|
||||
} as const;
|
||||
|
||||
|
||||
7
frontend/src/shared/styles/index.ts
Normal file
7
frontend/src/shared/styles/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared Styles
|
||||
* Export centralized style constants
|
||||
*/
|
||||
|
||||
export { Z_INDEX, Z_INDEX_CLASSES, type ZIndexLayer } from './z-index';
|
||||
|
||||
80
frontend/src/shared/styles/z-index.ts
Normal file
80
frontend/src/shared/styles/z-index.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Z-Index Hierarchy
|
||||
* Centralized z-index management for consistent layering
|
||||
*
|
||||
* 分层管理 z-index,确保组件层级一致性
|
||||
*/
|
||||
|
||||
export const Z_INDEX = {
|
||||
/**
|
||||
* Base layer - Normal content
|
||||
* 基础层 - 普通内容
|
||||
*/
|
||||
BASE: 0,
|
||||
|
||||
/**
|
||||
* Dropdown layer - Dropdown menus and selects (in normal pages)
|
||||
* 下拉层 - 普通页面的下拉菜单和选择框
|
||||
*/
|
||||
DROPDOWN: 50,
|
||||
|
||||
/**
|
||||
* Modal Dropdown layer - Dropdowns in modal (below modal container & header)
|
||||
* Modal 下拉层 - Modal 中的下拉框(低于 Modal 容器和标题)
|
||||
*/
|
||||
MODAL_DROPDOWN: 80,
|
||||
|
||||
/**
|
||||
* Sticky layer - Sticky headers and navigation
|
||||
* 粘性层 - 粘性头部和导航
|
||||
*/
|
||||
STICKY: 60,
|
||||
|
||||
/**
|
||||
* Modal backdrop layer - Modal overlay/backdrop
|
||||
* Modal 背景层
|
||||
*/
|
||||
MODAL_BACKDROP: 90,
|
||||
|
||||
/**
|
||||
* Modal layer - Modal dialogs and drawers
|
||||
* Modal 层 - 对话框和抽屉
|
||||
*/
|
||||
MODAL: 100,
|
||||
|
||||
/**
|
||||
* Popover layer - Popovers and tooltips inside modals
|
||||
* 弹出层 - Modal 内的弹出层和选择框
|
||||
*/
|
||||
POPOVER: 110,
|
||||
|
||||
/**
|
||||
* Toast layer - Global notifications
|
||||
* 通知层 - 全局通知消息
|
||||
*/
|
||||
TOAST: 200,
|
||||
|
||||
/**
|
||||
* Tooltip layer - Temporary tooltips (highest priority)
|
||||
* 工具提示层 - 临时提示信息(最高优先级)
|
||||
*/
|
||||
TOOLTIP: 300,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tailwind CSS z-index classes mapping
|
||||
* Tailwind CSS z-index 类映射
|
||||
*/
|
||||
export const Z_INDEX_CLASSES = {
|
||||
BASE: 'z-0',
|
||||
DROPDOWN: 'z-[50]',
|
||||
STICKY: 'z-[60]',
|
||||
MODAL_BACKDROP: 'z-[90]',
|
||||
MODAL: 'z-[100]',
|
||||
POPOVER: 'z-[110]',
|
||||
TOAST: 'z-[200]',
|
||||
TOOLTIP: 'z-[300]',
|
||||
} as const;
|
||||
|
||||
export type ZIndexLayer = keyof typeof Z_INDEX;
|
||||
|
||||
253
frontend/src/shared/utils/__tests__/case-converter.test.ts
Normal file
253
frontend/src/shared/utils/__tests__/case-converter.test.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Case Converter Test
|
||||
* 测试 snake_case ↔ camelCase 转换功能
|
||||
*/
|
||||
|
||||
import { snakeToCamel, camelToSnake, keysToCamel, keysToSnake } from '../case-converter';
|
||||
|
||||
describe('Case Converter', () => {
|
||||
// ==================== 字符串转换测试 ====================
|
||||
|
||||
describe('snakeToCamel', () => {
|
||||
it('should convert snake_case to camelCase', () => {
|
||||
expect(snakeToCamel('user_id')).toBe('userId');
|
||||
expect(snakeToCamel('created_at')).toBe('createdAt');
|
||||
expect(snakeToCamel('has_ca_data')).toBe('hasCaData');
|
||||
});
|
||||
|
||||
it('should handle single word', () => {
|
||||
expect(snakeToCamel('name')).toBe('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('camelToSnake', () => {
|
||||
it('should convert camelCase to snake_case', () => {
|
||||
expect(camelToSnake('userId')).toBe('user_id');
|
||||
expect(camelToSnake('createdAt')).toBe('created_at');
|
||||
expect(camelToSnake('hasCaData')).toBe('has_ca_data');
|
||||
});
|
||||
|
||||
it('should handle single word', () => {
|
||||
expect(camelToSnake('name')).toBe('name');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 对象转换测试 ====================
|
||||
|
||||
describe('keysToCamel', () => {
|
||||
it('should convert object keys from snake_case to camelCase', () => {
|
||||
const input = {
|
||||
user_id: '123',
|
||||
created_at: '2025-01-01',
|
||||
has_ca_data: true,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
userId: '123',
|
||||
createdAt: '2025-01-01',
|
||||
hasCaData: true,
|
||||
};
|
||||
|
||||
expect(keysToCamel(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const input = {
|
||||
user_id: '123',
|
||||
user_profile: {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
userId: '123',
|
||||
userProfile: {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
};
|
||||
|
||||
expect(keysToCamel(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const input = [
|
||||
{ user_id: '1', created_at: '2025-01-01' },
|
||||
{ user_id: '2', created_at: '2025-01-02' },
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{ userId: '1', createdAt: '2025-01-01' },
|
||||
{ userId: '2', createdAt: '2025-01-02' },
|
||||
];
|
||||
|
||||
expect(keysToCamel(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle arrays of nested objects', () => {
|
||||
const input = {
|
||||
items: [
|
||||
{ item_id: '1', item_name: 'Item 1' },
|
||||
{ item_id: '2', item_name: 'Item 2' },
|
||||
],
|
||||
};
|
||||
|
||||
const expected = {
|
||||
items: [
|
||||
{ itemId: '1', itemName: 'Item 1' },
|
||||
{ itemId: '2', itemName: 'Item 2' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(keysToCamel(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle null and undefined', () => {
|
||||
expect(keysToCamel(null)).toBeNull();
|
||||
expect(keysToCamel(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle primitive values', () => {
|
||||
expect(keysToCamel('string')).toBe('string');
|
||||
expect(keysToCamel(123)).toBe(123);
|
||||
expect(keysToCamel(true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keysToSnake', () => {
|
||||
it('should convert object keys from camelCase to snake_case', () => {
|
||||
const input = {
|
||||
userId: '123',
|
||||
createdAt: '2025-01-01',
|
||||
hasCaData: true,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
user_id: '123',
|
||||
created_at: '2025-01-01',
|
||||
has_ca_data: true,
|
||||
};
|
||||
|
||||
expect(keysToSnake(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const input = {
|
||||
userId: '123',
|
||||
userProfile: {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
user_id: '123',
|
||||
user_profile: {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
},
|
||||
};
|
||||
|
||||
expect(keysToSnake(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const input = [
|
||||
{ userId: '1', createdAt: '2025-01-01' },
|
||||
{ userId: '2', createdAt: '2025-01-02' },
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{ user_id: '1', created_at: '2025-01-01' },
|
||||
{ user_id: '2', created_at: '2025-01-02' },
|
||||
];
|
||||
|
||||
expect(keysToSnake(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 实际 API 场景测试 ====================
|
||||
|
||||
describe('API Response Conversion (snake_case → camelCase)', () => {
|
||||
it('should convert Cluster API response', () => {
|
||||
const apiResponse = {
|
||||
id: 'cluster-123',
|
||||
name: 'Production',
|
||||
host: 'https://k8s.example.com',
|
||||
has_ca_data: true,
|
||||
has_cert_data: true,
|
||||
has_key_data: false,
|
||||
has_token: true,
|
||||
ca_data: '••••••••',
|
||||
cert_data: '••••••••',
|
||||
created_at: '2025-11-09T10:00:00Z',
|
||||
updated_at: '2025-11-09T10:00:00Z',
|
||||
};
|
||||
|
||||
const frontendData = keysToCamel(apiResponse);
|
||||
|
||||
expect(frontendData.hasCAData).toBe(true);
|
||||
expect(frontendData.hasCertData).toBe(true);
|
||||
expect(frontendData.hasKeyData).toBe(false);
|
||||
expect(frontendData.hasToken).toBe(true);
|
||||
expect(frontendData.caData).toBe('••••••••');
|
||||
expect(frontendData.certData).toBe('••••••••');
|
||||
expect(frontendData.createdAt).toBe('2025-11-09T10:00:00Z');
|
||||
expect(frontendData.updatedAt).toBe('2025-11-09T10:00:00Z');
|
||||
});
|
||||
|
||||
it('should convert Instance API response', () => {
|
||||
const apiResponse = {
|
||||
id: 'instance-123',
|
||||
name: 'my-app',
|
||||
namespace: 'default',
|
||||
cluster_id: 'cluster-abc',
|
||||
registry_id: 'registry-xyz',
|
||||
created_at: '2025-11-09T10:00:00Z',
|
||||
updated_at: '2025-11-09T10:00:00Z',
|
||||
};
|
||||
|
||||
const frontendData = keysToCamel(apiResponse);
|
||||
|
||||
expect(frontendData.clusterId).toBe('cluster-abc');
|
||||
expect(frontendData.registryId).toBe('registry-xyz');
|
||||
expect(frontendData.createdAt).toBe('2025-11-09T10:00:00Z');
|
||||
expect(frontendData.updatedAt).toBe('2025-11-09T10:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Request Conversion (camelCase → snake_case)', () => {
|
||||
it('should convert CreateClusterRequest', () => {
|
||||
const requestData = {
|
||||
name: 'Production',
|
||||
host: 'https://k8s.example.com',
|
||||
caData: 'base64-encoded-ca',
|
||||
certData: 'base64-encoded-cert',
|
||||
keyData: 'base64-encoded-key',
|
||||
};
|
||||
|
||||
const apiRequest = keysToSnake(requestData);
|
||||
|
||||
expect(apiRequest.ca_data).toBe('base64-encoded-ca');
|
||||
expect(apiRequest.cert_data).toBe('base64-encoded-cert');
|
||||
expect(apiRequest.key_data).toBe('base64-encoded-key');
|
||||
});
|
||||
|
||||
it('should convert CreateInstanceRequest', () => {
|
||||
const requestData = {
|
||||
name: 'my-app',
|
||||
namespace: 'default',
|
||||
registryId: 'registry-123',
|
||||
repository: 'charts/nginx',
|
||||
tag: '1.0.0',
|
||||
};
|
||||
|
||||
const apiRequest = keysToSnake(requestData);
|
||||
|
||||
expect(apiRequest.registry_id).toBe('registry-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
217
frontend/src/shared/utils/api-helpers.ts
Normal file
217
frontend/src/shared/utils/api-helpers.ts
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* API Utility Functions
|
||||
* Unified API request and error handling with automatic token refresh
|
||||
* Automatically converts between snake_case (API) and camelCase (frontend)
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@/core/config/api-config";
|
||||
import { keysToCamel, keysToSnake } from "./case-converter";
|
||||
|
||||
// Flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Get authentication token
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem("access_token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token
|
||||
*/
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem("refresh_token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication headers
|
||||
*/
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Refresh token is invalid or expired
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh token:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API request options
|
||||
*/
|
||||
interface ApiRequestOptions extends Omit<RequestInit, "method" | "headers" | "body"> {
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
body?: unknown;
|
||||
signal?: AbortSignal; // Support request cancellation
|
||||
timeout?: number; // Request timeout (milliseconds), default 30 seconds
|
||||
skipAuthRefresh?: boolean; // Skip automatic token refresh
|
||||
skipCaseConversion?: boolean; // Skip automatic snake_case ↔ camelCase conversion
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified API request function with automatic token refresh
|
||||
* @param endpoint - API endpoint path (e.g. /v1/cluster-configs)
|
||||
* @param options - Request options
|
||||
* @returns Promise<T> - Returns JSON data
|
||||
* @throws Error - Throws error on request failure
|
||||
*/
|
||||
export async function apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
method = "GET",
|
||||
body,
|
||||
signal,
|
||||
timeout = 30000, // Default 30 second timeout
|
||||
skipAuthRefresh = false,
|
||||
skipCaseConversion = false,
|
||||
...restOptions
|
||||
} = options;
|
||||
|
||||
// Build complete URL
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
// Convert request body from camelCase to snake_case
|
||||
const requestBody = body && !skipCaseConversion ? keysToSnake(body) : body;
|
||||
|
||||
// Create timeout controller
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: getAuthHeaders(),
|
||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
...restOptions,
|
||||
signal: signal || controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (response.status === 401 && !skipAuthRefresh) {
|
||||
// Wait if a refresh is already in progress
|
||||
if (isRefreshing && refreshPromise) {
|
||||
const refreshed = await refreshPromise;
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
return apiRequest<T>(endpoint, { ...options, skipAuthRefresh: true });
|
||||
} else {
|
||||
// Refresh failed, redirect to login
|
||||
window.location.href = "/";
|
||||
throw new Error("Session expired, please login again");
|
||||
}
|
||||
}
|
||||
|
||||
// Start token refresh
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshAccessToken();
|
||||
|
||||
try {
|
||||
const refreshed = await refreshPromise;
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
return apiRequest<T>(endpoint, { ...options, skipAuthRefresh: true });
|
||||
} else {
|
||||
// Refresh failed, redirect to login
|
||||
window.location.href = "/";
|
||||
throw new Error("Session expired, please login again");
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error message
|
||||
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// Try various error message formats
|
||||
if (errorData.error) {
|
||||
// OCI standard error format
|
||||
if (typeof errorData.error === "object" && errorData.error.message) {
|
||||
errorMessage = errorData.error.message;
|
||||
} else if (typeof errorData.error === "string") {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
|
||||
errorMessage = errorData.errors[0].message || errorMessage;
|
||||
}
|
||||
} catch {
|
||||
// Unable to parse JSON, keep original error message
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle empty response (e.g. DELETE request)
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Convert response from snake_case to camelCase
|
||||
return skipCaseConversion ? responseData : keysToCamel<T>(responseData);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle timeout error
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`Request timeout: Server response time exceeded ${timeout / 1000} seconds`);
|
||||
}
|
||||
|
||||
// Handle network error
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error('Network connection failed: Please check network connection or backend service status');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
108
frontend/src/shared/utils/case-converter.ts
Normal file
108
frontend/src/shared/utils/case-converter.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Case Converter Utility
|
||||
* 自动转换 snake_case ↔ camelCase
|
||||
* 用于前端(camelCase)与后端 API(snake_case)之间的数据转换
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将 snake_case 字符串转换为 camelCase
|
||||
* @example snakeToCamel('user_id') → 'userId'
|
||||
*/
|
||||
export function snakeToCamel(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 camelCase 字符串转换为 snake_case
|
||||
* @example camelToSnake('userId') → 'user_id'
|
||||
*/
|
||||
export function camelToSnake(str: string): string {
|
||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度转换对象的所有键从 snake_case 到 camelCase
|
||||
* @param obj - 要转换的对象(可能包含嵌套对象和数组)
|
||||
* @returns 转换后的对象
|
||||
*/
|
||||
export function keysToCamel<T = any>(obj: any): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => keysToCamel(item)) as any;
|
||||
}
|
||||
|
||||
// 处理普通对象
|
||||
if (typeof obj === 'object' && obj.constructor === Object) {
|
||||
const converted: any = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const camelKey = snakeToCamel(key);
|
||||
converted[camelKey] = keysToCamel(obj[key]);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
// 其他类型(string, number, boolean 等)直接返回
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度转换对象的所有键从 camelCase 到 snake_case
|
||||
* @param obj - 要转换的对象(可能包含嵌套对象和数组)
|
||||
* @returns 转换后的对象
|
||||
*/
|
||||
export function keysToSnake<T = any>(obj: any): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => keysToSnake(item)) as any;
|
||||
}
|
||||
|
||||
// 处理普通对象
|
||||
if (typeof obj === 'object' && obj.constructor === Object) {
|
||||
const converted: any = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const snakeKey = camelToSnake(key);
|
||||
converted[snakeKey] = keysToSnake(obj[key]);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
// 其他类型(string, number, boolean 等)直接返回
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换函数示例:
|
||||
*
|
||||
* // API 响应 (snake_case) → 前端 (camelCase)
|
||||
* const apiResponse = {
|
||||
* user_id: "123",
|
||||
* created_at: "2025-01-01",
|
||||
* user_profile: {
|
||||
* first_name: "John"
|
||||
* }
|
||||
* };
|
||||
* const frontendData = keysToCamel(apiResponse);
|
||||
* // { userId: "123", createdAt: "2025-01-01", userProfile: { firstName: "John" } }
|
||||
*
|
||||
* // 前端 (camelCase) → API 请求 (snake_case)
|
||||
* const requestData = {
|
||||
* userId: "123",
|
||||
* firstName: "John"
|
||||
* };
|
||||
* const apiRequest = keysToSnake(requestData);
|
||||
* // { user_id: "123", first_name: "John" }
|
||||
*/
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user