refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@ -14,7 +14,8 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@ -8688,16 +8689,18 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"dev": true,
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
|
||||
@ -18,7 +18,8 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
@ -76,6 +76,8 @@ import type {
|
||||
PutRegistriesRegistryIdPathParameters,
|
||||
} from './generated-orval/api.schemas';
|
||||
|
||||
import { customAxiosInstance } from './axios-mutator';
|
||||
|
||||
import {
|
||||
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
|
||||
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus as GeneratedInstanceStatusEnum,
|
||||
@ -91,9 +93,46 @@ export type * from './generated-orval/api.schemas';
|
||||
// ---------- Friendly type aliases ----------
|
||||
export type AuthResponse = GeneratedAuthResponse;
|
||||
export type RegisterBody = GeneratedRegisterRequest;
|
||||
export type AdminCreateUserRequest = RegisterBody & {
|
||||
role?: string;
|
||||
workspaceId?: string;
|
||||
namespace?: string;
|
||||
defaultClusterId?: string;
|
||||
quotaCpu?: string;
|
||||
quotaMemory?: string;
|
||||
quotaGpu?: string;
|
||||
quotaGpuMemory?: string;
|
||||
isActive?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type LoginBody = GeneratedLoginRequest;
|
||||
export type RefreshTokenBody = GeneratedRefreshTokenRequest;
|
||||
export type UserResponse = GeneratedUserResponse;
|
||||
export type UserResponse = GeneratedUserResponse & {
|
||||
role?: string;
|
||||
workspaceId?: string;
|
||||
workspaceName?: string;
|
||||
namespace?: string;
|
||||
defaultClusterId?: string;
|
||||
quotaCpu?: string;
|
||||
quotaMemory?: string;
|
||||
quotaGpu?: string;
|
||||
quotaGpuMemory?: string;
|
||||
isActive?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type UpdateUserRequest = {
|
||||
role?: string;
|
||||
workspaceId?: string;
|
||||
namespace?: string;
|
||||
defaultClusterId?: string;
|
||||
quotaCpu?: string;
|
||||
quotaMemory?: string;
|
||||
quotaGpu?: string;
|
||||
quotaGpuMemory?: string;
|
||||
isActive?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type ValuesYamlResponse = { valuesYaml: string };
|
||||
|
||||
export type ClusterResponse = GeneratedClusterResponse;
|
||||
export type CreateClusterRequest = GeneratedCreateClusterRequest;
|
||||
@ -108,6 +147,49 @@ export type InstanceResponse = GeneratedInstanceResponse;
|
||||
export type CreateInstanceRequest = GeneratedCreateInstanceRequest;
|
||||
export type UpdateInstanceRequest = GeneratedUpdateInstanceRequest;
|
||||
export type InstanceEntry = GeneratedInstanceEntry;
|
||||
export type InstanceDiagnosticsResponse = {
|
||||
instanceName?: string;
|
||||
namespace?: string;
|
||||
collectedAt?: string;
|
||||
pods?: Array<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
phase?: string;
|
||||
nodeName?: string;
|
||||
podIp?: string;
|
||||
hostIp?: string;
|
||||
restartCount?: number;
|
||||
containers?: Array<{
|
||||
name?: string;
|
||||
image?: string;
|
||||
ready?: boolean;
|
||||
restartCount?: number;
|
||||
state?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
conditions?: Array<{ type?: string; status?: string; reason?: string; message?: string }>;
|
||||
creationTimestamp?: string;
|
||||
}>;
|
||||
services?: Array<{
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
type?: string;
|
||||
clusterIP?: string;
|
||||
ports?: Array<{ name?: string; protocol?: string; port?: number; targetPort?: string; nodePort?: number }>;
|
||||
}>;
|
||||
events?: Array<{
|
||||
type?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
involvedKind?: string;
|
||||
involvedName?: string;
|
||||
count?: number;
|
||||
firstTimestamp?: string;
|
||||
lastTimestamp?: string;
|
||||
}>;
|
||||
logs?: Array<{ pod?: string; container?: string; tailLines?: number; log?: string; error?: string }>;
|
||||
};
|
||||
export const INSTANCE_STATUS = GeneratedInstanceStatusEnum;
|
||||
export type InstanceStatus = NonNullable<InstanceResponse['status']>;
|
||||
export const INSTANCE_LAST_OPERATION = GeneratedInstanceLastOperationEnum;
|
||||
@ -134,6 +216,13 @@ export type NodeMetricsResponse = GeneratedNodeMetricsResponse;
|
||||
export const login = postAuthLogin;
|
||||
export const register = postAuthRegister;
|
||||
export const refreshAuth = postAuthRefresh;
|
||||
export const listUsers = () => customAxiosInstance<UserResponse[]>({ url: "/users", method: "GET" });
|
||||
export const createUser = (data: AdminCreateUserRequest) =>
|
||||
customAxiosInstance<UserResponse>({ url: "/users", method: "POST", data });
|
||||
export const updateUser = (userId: string, data: UpdateUserRequest) =>
|
||||
customAxiosInstance<UserResponse>({ url: `/users/${encodeURIComponent(userId)}`, method: "PUT", data });
|
||||
export const deleteUser = (userId: string) =>
|
||||
customAxiosInstance<void>({ url: `/users/${encodeURIComponent(userId)}`, method: "DELETE" });
|
||||
|
||||
export const listClusters = getClusters;
|
||||
export const createCluster = postClusters;
|
||||
@ -148,6 +237,15 @@ export const getInstance = getClustersClusterIdInstancesInstanceId;
|
||||
export const updateInstance = putClustersClusterIdInstancesInstanceId;
|
||||
export const deleteInstance = deleteClustersClusterIdInstancesInstanceId;
|
||||
export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries;
|
||||
export const getInstanceDiagnostics = (
|
||||
params: { clusterId: string; instanceId: string },
|
||||
options?: { tailLines?: number },
|
||||
) =>
|
||||
customAxiosInstance<InstanceDiagnosticsResponse>({
|
||||
url: `/clusters/${encodeURIComponent(params.clusterId)}/instances/${encodeURIComponent(params.instanceId)}/diagnostics`,
|
||||
method: "GET",
|
||||
params: options?.tailLines ? { tailLines: options.tailLines } : undefined,
|
||||
});
|
||||
|
||||
export const listRegistries = getRegistries;
|
||||
export const createRegistry = postRegistries;
|
||||
@ -156,7 +254,13 @@ export const updateRegistry = putRegistriesRegistryId;
|
||||
export const deleteRegistry = deleteRegistriesRegistryId;
|
||||
export const checkRegistryHealth = getRegistriesRegistryIdHealth;
|
||||
|
||||
export const listRepositories = getRegistriesRegistryIdRepositories;
|
||||
export const listRepositories = (
|
||||
params: GetRegistriesRegistryIdRepositoriesPathParameters,
|
||||
options?: { artifactType?: 'chart' | 'all' },
|
||||
) =>
|
||||
getRegistriesRegistryIdRepositories(params, {
|
||||
params: options?.artifactType ? { artifact_type: options.artifactType } : undefined,
|
||||
});
|
||||
type ListArtifactsRequestOptions = AxiosOptions<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts>;
|
||||
|
||||
export const listArtifacts = (
|
||||
@ -173,6 +277,11 @@ export const listArtifacts = (
|
||||
|
||||
export const getArtifact = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference;
|
||||
export const getValuesSchema = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema;
|
||||
export const getValuesYaml = (params: GetValuesSchemaPathParameters) =>
|
||||
customAxiosInstance<ValuesYamlResponse>({
|
||||
url: `/registries/${encodeURIComponent(params.registryId)}/repositories/${encodeURIComponent(params.repositoryName)}/artifacts/${encodeURIComponent(params.reference)}/values-yaml`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
export const listClusterMonitoring = getMonitoringClusters;
|
||||
export const getClusterMonitoring = getMonitoringClustersClusterId;
|
||||
|
||||
@ -16,18 +16,22 @@ import { getNavItems } from "./constants/navigation";
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, login, logout } = useAuth();
|
||||
const { isAuthenticated, login, logout, user } = useAuth();
|
||||
|
||||
// Generate navigation items based on current location
|
||||
const navItems = useMemo(
|
||||
() => getNavItems(location.pathname, navigate),
|
||||
[location.pathname, navigate]
|
||||
() => getNavItems(location.pathname, navigate, user),
|
||||
[location.pathname, navigate, user]
|
||||
);
|
||||
const displayName = user?.workspaceName
|
||||
? `${user.username || "User"} · ${user.workspaceName}`
|
||||
: user?.username || "User";
|
||||
|
||||
return (
|
||||
<AppRoutes
|
||||
isAuthenticated={isAuthenticated}
|
||||
userName="User"
|
||||
userName={displayName}
|
||||
user={user}
|
||||
navItems={navItems}
|
||||
onLogin={login}
|
||||
onLogout={logout}
|
||||
@ -35,4 +39,3 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
* 导航配置 - 集中管理导航菜单项
|
||||
*/
|
||||
|
||||
import { Home, Settings, Server, Database, Package, LineChart } from "lucide-react";
|
||||
import { Home, Settings, Server, Database, LineChart, Users, Rocket, Boxes } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { User } from "../providers/AuthContext";
|
||||
import { canAccessRoute } from "../providers/auth-model";
|
||||
|
||||
/**
|
||||
* Navigation item type
|
||||
@ -11,7 +14,7 @@ import { Home, Settings, Server, Database, Package, LineChart } from "lucide-rea
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
children?: NavItem[];
|
||||
@ -22,7 +25,7 @@ export interface NavItem {
|
||||
*/
|
||||
export interface PageInfo {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,75 +36,95 @@ export interface PageInfo {
|
||||
*/
|
||||
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
navigate: (path: string) => void,
|
||||
user?: User | null
|
||||
): NavItem[] => {
|
||||
const canAccess = (path: string) => (user ? canAccessRoute(path, user) : true);
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
key: "home",
|
||||
label: "Home",
|
||||
icon: <Home className="w-4 h-4 text-secondary" />,
|
||||
active: currentPath === "/home",
|
||||
onClick: () => navigate("/home"),
|
||||
},
|
||||
{
|
||||
key: "artifact-registries",
|
||||
label: "Launch Instance",
|
||||
icon: <Rocket className="w-4 h-4 text-blue-600" />,
|
||||
active: currentPath === "/artifact/registries",
|
||||
onClick: () => navigate("/artifact/registries"),
|
||||
},
|
||||
{
|
||||
key: "artifact-instances",
|
||||
label: "Instances",
|
||||
icon: <Boxes className="w-4 h-4 text-emerald-600" />,
|
||||
active: currentPath === "/artifact/instances",
|
||||
onClick: () => navigate("/artifact/instances"),
|
||||
},
|
||||
{
|
||||
key: "monitoring-clusters",
|
||||
label: "Cluster Monitoring",
|
||||
icon: <LineChart className="w-4 h-4 text-accent-teal" />,
|
||||
active: currentPath === "/monitoring/clusters",
|
||||
onClick: () => navigate("/monitoring/clusters"),
|
||||
},
|
||||
{
|
||||
key: "setup",
|
||||
label: "Setup",
|
||||
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"),
|
||||
},
|
||||
{
|
||||
key: "configuration-users",
|
||||
label: "Users",
|
||||
icon: <Users className="w-4 h-4 text-blue-600" />,
|
||||
active: currentPath === "/configuration/users",
|
||||
onClick: () => navigate("/configuration/users"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children?.filter((child) => {
|
||||
const routePath = routePathByKey[child.key];
|
||||
return routePath ? canAccess(routePath) : true;
|
||||
}),
|
||||
}))
|
||||
.filter((item) => {
|
||||
const routePath = routePathByKey[item.key];
|
||||
if (routePath && !canAccess(routePath)) {
|
||||
return false;
|
||||
}
|
||||
return !item.children || item.children.length > 0;
|
||||
});
|
||||
};
|
||||
|
||||
const routePathByKey: Record<string, string> = {
|
||||
home: "/home",
|
||||
"artifact-registries": "/artifact/registries",
|
||||
"artifact-instances": "/artifact/instances",
|
||||
"monitoring-clusters": "/monitoring/clusters",
|
||||
"configuration-clusters": "/configuration/clusters",
|
||||
"configuration-registries": "/configuration/registries",
|
||||
"configuration-users": "/configuration/users",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get page header info based on current path
|
||||
@ -110,21 +133,22 @@ export const getNavItems = (
|
||||
*/
|
||||
export const getPageInfo = (pathname: string): PageInfo => {
|
||||
if (pathname === "/artifact/registries") {
|
||||
return { title: "Artifact Browser", icon: <Package className="w-6 h-6 text-brand-light" /> };
|
||||
return { title: "Launch Instance", icon: <Rocket className="w-6 h-6 text-blue-600" /> };
|
||||
}
|
||||
if (pathname === "/artifact/instances") {
|
||||
return { title: "Artifact - Instances", icon: <Package className="w-6 h-6 text-brand-accent" /> };
|
||||
return { title: "Instances", icon: <Boxes className="w-6 h-6 text-emerald-600" /> };
|
||||
}
|
||||
if (pathname === "/configuration/clusters") {
|
||||
return { title: "Configuration - Clusters", icon: <Server className="w-6 h-6 text-accent-teal" /> };
|
||||
return { title: "Setup - 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" /> };
|
||||
return { title: "Setup - Registries", icon: <Database className="w-6 h-6 text-brand-light" /> };
|
||||
}
|
||||
if (pathname === "/configuration/users") {
|
||||
return { title: "Setup - Users", icon: <Users className="w-6 h-6 text-blue-600" /> };
|
||||
}
|
||||
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" /> };
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -6,18 +6,32 @@
|
||||
import { createContext } from "react";
|
||||
import type { AuthResponse } from "@/api";
|
||||
|
||||
export type UserRole = "admin" | "user" | string;
|
||||
|
||||
export interface User {
|
||||
userId?: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
role: UserRole;
|
||||
workspaceId?: string;
|
||||
workspaceName?: string;
|
||||
namespace?: string;
|
||||
defaultClusterId?: string;
|
||||
quotaCpu?: string;
|
||||
quotaMemory?: string;
|
||||
quotaGpu?: string;
|
||||
quotaGpuMemory?: string;
|
||||
permissions: string[];
|
||||
permissionVersion?: string | number;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isHydratingUser: boolean;
|
||||
login: (response: AuthResponse) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AuthResponse } from "@/api";
|
||||
import { setAuthToken } from "@/api";
|
||||
import { AXIOS_INSTANCE, setAuthToken } from "@/api";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
import { AuthContext, type User } from "./AuthContext";
|
||||
import { normalizeUser } from "./auth-model";
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
@ -19,15 +21,66 @@ interface AuthProviderProps {
|
||||
* 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);
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
if (devMode) return "dev-token";
|
||||
return localStorage.getItem("access_token");
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
if (devMode) return null;
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (!storedUser) return null;
|
||||
try {
|
||||
return normalizeUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
localStorage.removeItem("user");
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [isHydratingUser, setIsHydratingUser] = useState(false);
|
||||
|
||||
if (token) {
|
||||
setAuthToken(token);
|
||||
}
|
||||
|
||||
const persistUser = (nextUser: User) => {
|
||||
localStorage.setItem("user", JSON.stringify(nextUser));
|
||||
setUser(nextUser);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
const activeToken = localStorage.getItem("access_token");
|
||||
if (!activeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsHydratingUser(true);
|
||||
try {
|
||||
setAuthToken(activeToken);
|
||||
const response = await AXIOS_INSTANCE.get("/auth/me");
|
||||
const nextUser = normalizeUser(response.data as Record<string, unknown>, user);
|
||||
persistUser(nextUser);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) {
|
||||
logout();
|
||||
} else if (error?.response?.status !== 404) {
|
||||
console.info("Unable to hydrate user profile from /auth/me:", error);
|
||||
}
|
||||
} finally {
|
||||
setIsHydratingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize: read token and user from localStorage
|
||||
useEffect(() => {
|
||||
if (devMode) {
|
||||
const devUser: User = {
|
||||
userId: "dev-user",
|
||||
username: "dev-user",
|
||||
role: "admin",
|
||||
workspaceId: "dev-workspace",
|
||||
workspaceName: "Development",
|
||||
namespace: "ocdp-ws-development",
|
||||
permissions: ["*"],
|
||||
};
|
||||
localStorage.setItem("access_token", "dev-token");
|
||||
localStorage.setItem("user", JSON.stringify(devUser));
|
||||
@ -37,18 +90,15 @@ export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) =
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem("access_token");
|
||||
const storedUser = localStorage.getItem("user");
|
||||
|
||||
if (storedToken) {
|
||||
setToken(storedToken);
|
||||
setAuthToken(storedToken);
|
||||
}
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored user:", e);
|
||||
}
|
||||
if (storedToken) {
|
||||
void refreshUser();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [devMode]);
|
||||
|
||||
// Sync token changes to axios headers
|
||||
@ -65,14 +115,12 @@ export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) =
|
||||
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));
|
||||
const nextUser = normalizeUser(response as Record<string, unknown>);
|
||||
|
||||
setToken(accessToken);
|
||||
setUser(user);
|
||||
setAuthToken(accessToken);
|
||||
persistUser(nextUser);
|
||||
void refreshUser();
|
||||
};
|
||||
|
||||
// Handle logout
|
||||
@ -83,6 +131,7 @@ export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) =
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setAuthToken(null);
|
||||
globalCache.clearAll();
|
||||
};
|
||||
|
||||
return (
|
||||
@ -91,8 +140,10 @@ export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) =
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: !!token,
|
||||
isHydratingUser,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
149
frontend/src/app/providers/auth-model.ts
Normal file
149
frontend/src/app/providers/auth-model.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import type { AuthResponse, UserResponse } from "@/api";
|
||||
import type { User } from "./AuthContext";
|
||||
|
||||
type AuthLike = Partial<AuthResponse & UserResponse> & Record<string, unknown>;
|
||||
|
||||
export const DEFAULT_USER_PERMISSIONS = [
|
||||
"home:view",
|
||||
"configuration:clusters:manage_own",
|
||||
"configuration:registries:manage_own",
|
||||
"artifact:registries:view",
|
||||
"artifact:instances:manage_own",
|
||||
];
|
||||
|
||||
const ADMIN_ROLE = "admin";
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||
: [];
|
||||
|
||||
export const normalizeUser = (source: AuthLike, fallback?: User | null): User => {
|
||||
const role = asString(source.role) ?? fallback?.role ?? "user";
|
||||
const permissions = asStringArray(source.permissions);
|
||||
|
||||
return {
|
||||
userId: asString(source.userId) ?? asString(source.id) ?? fallback?.userId,
|
||||
username: asString(source.username) ?? fallback?.username ?? "",
|
||||
role,
|
||||
workspaceId: asString(source.workspaceId) ?? fallback?.workspaceId,
|
||||
workspaceName: asString(source.workspaceName) ?? fallback?.workspaceName,
|
||||
namespace: asString(source.namespace) ?? fallback?.namespace,
|
||||
defaultClusterId: asString(source.defaultClusterId) ?? fallback?.defaultClusterId,
|
||||
quotaCpu: asString(source.quotaCpu) ?? fallback?.quotaCpu,
|
||||
quotaMemory: asString(source.quotaMemory) ?? fallback?.quotaMemory,
|
||||
quotaGpu: asString(source.quotaGpu) ?? fallback?.quotaGpu,
|
||||
quotaGpuMemory: asString(source.quotaGpuMemory) ?? fallback?.quotaGpuMemory,
|
||||
permissions: permissions.length > 0 ? permissions : fallback?.permissions ?? DEFAULT_USER_PERMISSIONS,
|
||||
permissionVersion:
|
||||
typeof source.permissionVersion === "string" || typeof source.permissionVersion === "number"
|
||||
? source.permissionVersion
|
||||
: fallback?.permissionVersion,
|
||||
};
|
||||
};
|
||||
|
||||
export const isAdminUser = (user: User | null | undefined): boolean =>
|
||||
user?.role?.toLowerCase() === ADMIN_ROLE;
|
||||
|
||||
export const hasPermission = (
|
||||
user: User | null | undefined,
|
||||
permission: string,
|
||||
fallbackAllowed = true
|
||||
): boolean => {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (isAdminUser(user)) {
|
||||
return true;
|
||||
}
|
||||
if (user.permissions.length === 0) {
|
||||
return fallbackAllowed;
|
||||
}
|
||||
return user.permissions.includes(permission) || user.permissions.includes("*");
|
||||
};
|
||||
|
||||
export const canAccessRoute = (pathname: string, user: User | null | undefined): boolean => {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname === "/admin" || pathname.startsWith("/admin/")) return isAdminUser(user);
|
||||
if (pathname === "/configuration/users" || pathname === "/configuration/workspaces") {
|
||||
return isAdminUser(user);
|
||||
}
|
||||
|
||||
if (pathname === "/home") return hasPermission(user, "home:view");
|
||||
if (pathname === "/configuration/clusters") {
|
||||
return (
|
||||
hasPermission(user, "configuration:clusters:manage", false) ||
|
||||
hasPermission(user, "configuration:clusters:manage_own")
|
||||
);
|
||||
}
|
||||
if (pathname === "/configuration/registries") {
|
||||
return (
|
||||
hasPermission(user, "configuration:registries:manage", false) ||
|
||||
hasPermission(user, "configuration:registries:manage_own")
|
||||
);
|
||||
}
|
||||
if (pathname === "/artifact/registries") return hasPermission(user, "artifact:registries:view");
|
||||
if (pathname === "/artifact/instances") {
|
||||
return (
|
||||
hasPermission(user, "artifact:instances:manage", false) ||
|
||||
hasPermission(user, "artifact:instances:manage_own")
|
||||
);
|
||||
}
|
||||
if (pathname === "/monitoring/clusters") {
|
||||
return hasPermission(user, "monitoring:clusters:view", isAdminUser(user));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export type ResourceVisibility = "private" | "workspace_shared" | "global_shared" | string;
|
||||
|
||||
export type ResourceWithAccess = {
|
||||
visibility?: ResourceVisibility;
|
||||
ownerId?: string;
|
||||
allowedActions?: string[];
|
||||
};
|
||||
|
||||
export const canUseResourceAction = (
|
||||
resource: ResourceWithAccess,
|
||||
action: "view" | "create" | "update" | "delete" | "test" | "launch",
|
||||
user: User | null | undefined
|
||||
): boolean => {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (isAdminUser(user)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(resource.allowedActions) && resource.allowedActions.length > 0) {
|
||||
const allowedActions = resource.allowedActions.map((allowedAction) => allowedAction.toLowerCase());
|
||||
const aliases = actionAliases[action] ?? [action];
|
||||
return allowedActions.includes("*") || aliases.some((alias) => allowedActions.includes(alias));
|
||||
}
|
||||
if (!resource.ownerId) {
|
||||
return true;
|
||||
}
|
||||
return resource.ownerId === user.userId;
|
||||
};
|
||||
|
||||
export const getVisibilityLabel = (visibility?: ResourceVisibility): string => {
|
||||
if (visibility === "workspace_shared") return "Workspace";
|
||||
if (visibility === "global_shared") return "Global";
|
||||
if (visibility === "private") return "Private";
|
||||
return visibility ? visibility.replace(/_/g, " ") : "Private";
|
||||
};
|
||||
|
||||
const actionAliases: Record<string, string[]> = {
|
||||
view: ["view", "read"],
|
||||
create: ["create", "add"],
|
||||
update: ["update", "edit", "manage"],
|
||||
delete: ["delete", "remove", "manage"],
|
||||
test: ["test", "health", "manage"],
|
||||
launch: ["launch", "deploy"],
|
||||
};
|
||||
24
frontend/src/app/routes/AccessDeniedPage.tsx
Normal file
24
frontend/src/app/routes/AccessDeniedPage.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
interface AccessDeniedPageProps {
|
||||
onBackHome: () => void;
|
||||
}
|
||||
|
||||
export const AccessDeniedPage = ({ onBackHome }: AccessDeniedPageProps) => (
|
||||
<div className="mx-auto flex min-h-[55vh] max-w-xl flex-col items-center justify-center text-center">
|
||||
<div className="mb-5 rounded-full border border-red-100 bg-red-50 p-4">
|
||||
<ShieldAlert className="h-10 w-10 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-slate-900">Access denied</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Your current role or workspace permissions do not allow this page. If access was just granted,
|
||||
sign out and sign back in to refresh the permission version.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button type="button" variant="primary" onClick={onBackHome}>
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -4,17 +4,22 @@
|
||||
*/
|
||||
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { ProtectedRoute } from "./RouteGuard";
|
||||
import { AccessDeniedPage } from "./AccessDeniedPage";
|
||||
import AppShell from "@/shared/components/layout/AppShell";
|
||||
import { getPageInfo, type NavItem } from "../constants/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import type { AuthResponse } from "@/api";
|
||||
import type { User } from "../providers/AuthContext";
|
||||
import { canAccessRoute } from "../providers/auth-model";
|
||||
|
||||
// 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 UserManagementPage from "@/features/configuration/users/pages/UserManagementPage";
|
||||
import ArtifactBrowserPage from "@/features/artifact/registries/pages/ArtifactBrowserPage";
|
||||
import InstancesManagementPage from "@/features/artifact/instances/pages/InstancesManagementPage";
|
||||
import MonitoringClustersPage from "@/features/monitoring/clusters/pages/MonitoringClustersPage";
|
||||
@ -23,6 +28,7 @@ import { ApiTest } from "@/components/ApiTest";
|
||||
interface AppRoutesProps {
|
||||
isAuthenticated: boolean;
|
||||
userName?: string;
|
||||
user: User | null;
|
||||
navItems: NavItem[];
|
||||
onLogin: (tokens: AuthResponse) => void;
|
||||
onLogout: () => void;
|
||||
@ -34,12 +40,31 @@ interface AppRoutesProps {
|
||||
export const AppRoutes = ({
|
||||
isAuthenticated,
|
||||
userName = "User",
|
||||
user,
|
||||
navItems,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: AppRoutesProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const pageInfo = getPageInfo(location.pathname);
|
||||
const shell = (children: ReactNode) => (
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
userRole={user?.role}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
const protectedPage = (path: string, children: ReactNode) => (
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated} isAllowed={canAccessRoute(path, user)}>
|
||||
{shell(children)}
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
@ -58,102 +83,54 @@ export const AppRoutes = ({
|
||||
{/* 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>
|
||||
}
|
||||
element={protectedPage("/home", <HomePage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/configuration/clusters"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
<ClusterConfigPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
element={protectedPage("/configuration/clusters", <ClusterConfigPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/configuration/registries"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
<RegistryConfigPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
element={protectedPage("/configuration/registries", <RegistryConfigPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/artifact/registries"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
<ArtifactBrowserPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
element={protectedPage("/artifact/registries", <ArtifactBrowserPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/artifact/instances"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
<InstancesManagementPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
element={protectedPage("/artifact/instances", <InstancesManagementPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/monitoring/clusters"
|
||||
element={protectedPage("/monitoring/clusters", <MonitoringClustersPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={protectedPage("/admin", <AccessDeniedPage onBackHome={() => navigate("/home")} />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/configuration/users"
|
||||
element={protectedPage("/configuration/users", <UserManagementPage />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/configuration/workspaces"
|
||||
element={protectedPage("/configuration/workspaces", <AccessDeniedPage onBackHome={() => navigate("/home")} />)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/forbidden"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<AppShell
|
||||
title={pageInfo.title}
|
||||
icon={pageInfo.icon}
|
||||
userName={userName}
|
||||
navItems={navItems}
|
||||
onSignOut={onLogout}
|
||||
>
|
||||
<MonitoringClustersPage />
|
||||
</AppShell>
|
||||
{shell(<AccessDeniedPage onBackHome={() => navigate("/home")} />)}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@ -178,5 +155,3 @@ export const AppRoutes = ({
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -5,9 +5,11 @@
|
||||
|
||||
import { Navigate } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import type { User } from "../providers/AuthContext";
|
||||
|
||||
interface RouteGuardProps {
|
||||
isAuthenticated: boolean;
|
||||
isAllowed?: boolean;
|
||||
redirectTo?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
@ -17,11 +19,16 @@ interface RouteGuardProps {
|
||||
* Redirects to auth page if not authenticated
|
||||
*/
|
||||
export const ProtectedRoute = ({
|
||||
isAuthenticated,
|
||||
isAuthenticated,
|
||||
isAllowed = true,
|
||||
redirectTo = "/",
|
||||
children
|
||||
}: RouteGuardProps) => {
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
return isAllowed ? <>{children}</> : <Navigate to="/forbidden" replace />;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -36,4 +43,6 @@ export const PublicRoute = ({
|
||||
return !isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
|
||||
};
|
||||
|
||||
export const canUseRoute = (user: User | null, predicate?: (user: User) => boolean): boolean =>
|
||||
!predicate || (user ? predicate(user) : false);
|
||||
|
||||
|
||||
@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Activity, AlertTriangle, Box, Copy, FileText, RotateCw, Server, Terminal, X } from "lucide-react";
|
||||
import { getInstanceDiagnostics, type InstanceDiagnosticsResponse, type InstanceResponse } from "@/api";
|
||||
import { Button, Badge, LoadingState } from "@/shared/components";
|
||||
import { formatApiError } from "@/shared/utils";
|
||||
import { useToast } from "@/shared";
|
||||
|
||||
type TabKey = "summary" | "events" | "logs";
|
||||
|
||||
interface DiagnosticsModalProps {
|
||||
instance: InstanceResponse;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, onClose }) => {
|
||||
const { success, error: toastError } = useToast();
|
||||
const [data, setData] = useState<InstanceDiagnosticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("summary");
|
||||
|
||||
const loadDiagnostics = async () => {
|
||||
if (!instance.clusterId || !instance.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await getInstanceDiagnostics({ clusterId: instance.clusterId, instanceId: instance.id }, { tailLines: 300 }));
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load diagnostics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDiagnostics();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instance.clusterId, instance.id]);
|
||||
|
||||
const combinedLogs = useMemo(
|
||||
() =>
|
||||
(data?.logs ?? [])
|
||||
.map((entry) => `# ${entry.pod || "pod"} / ${entry.container || "container"}\n${entry.error || entry.log || ""}`)
|
||||
.join("\n\n"),
|
||||
[data?.logs]
|
||||
);
|
||||
|
||||
const copyLogs = async () => {
|
||||
await navigator.clipboard.writeText(combinedLogs);
|
||||
success("Logs copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<Activity className="h-4 w-4" />
|
||||
Runtime diagnostics
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-slate-950">{instance.name}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{instance.namespace} · {data?.collectedAt ? new Date(data.collectedAt).toLocaleString() : "live Kubernetes data"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" icon={RotateCw} onClick={loadDiagnostics} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<button onClick={onClose} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-200 px-6 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TabButton active={activeTab === "summary"} onClick={() => setActiveTab("summary")} icon={Box} label="Describe" />
|
||||
<TabButton active={activeTab === "events"} onClick={() => setActiveTab("events")} icon={AlertTriangle} label="Events" />
|
||||
<TabButton active={activeTab === "logs"} onClick={() => setActiveTab("logs")} icon={Terminal} label="Pod Logs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<LoadingState message="Loading Kubernetes diagnostics..." />
|
||||
) : !data ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Diagnostics data is not available.
|
||||
</div>
|
||||
) : activeTab === "summary" ? (
|
||||
<SummaryTab data={data} />
|
||||
) : activeTab === "events" ? (
|
||||
<EventsTab data={data} />
|
||||
) : (
|
||||
<LogsTab data={data} combinedLogs={combinedLogs} onCopy={copyLogs} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TabButton: React.FC<{
|
||||
active: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}> = ({ active, icon: Icon, label, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
active ? "bg-blue-600 text-white shadow-sm" : "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-950"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SummaryTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<MetricCard icon={Box} label="Pods" value={data.pods?.length ?? 0} />
|
||||
<MetricCard icon={Server} label="Services" value={data.services?.length ?? 0} />
|
||||
<MetricCard icon={AlertTriangle} label="Events" value={data.events?.length ?? 0} />
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Pods</h3>
|
||||
{(data.pods ?? []).length === 0 ? (
|
||||
<EmptyLine text="No pods matched this Helm release." />
|
||||
) : (
|
||||
(data.pods ?? []).map((pod) => (
|
||||
<div key={pod.name} className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="font-mono text-sm font-semibold text-slate-950">{pod.name}</h4>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{pod.nodeName || "unscheduled"} · podIP {pod.podIp || "-"} · restarts {pod.restartCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={pod.phase === "Running" ? "success" : "warning"} size="sm">
|
||||
{pod.phase || "Unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{(pod.containers ?? []).map((container) => (
|
||||
<div key={container.name} className="rounded-md border border-slate-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-900">{container.name}</span>
|
||||
<Badge variant={container.ready ? "success" : "warning"} size="sm">
|
||||
{container.state || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-xs text-slate-500" title={container.image}>
|
||||
{container.image}
|
||||
</p>
|
||||
{(container.reason || container.message) && (
|
||||
<p className="mt-2 text-xs text-amber-700">{container.reason || container.message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Services</h3>
|
||||
{(data.services ?? []).length === 0 ? <EmptyLine text="No services matched this Helm release." /> : null}
|
||||
{(data.services ?? []).map((svc) => (
|
||||
<div key={svc.name} className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm font-semibold text-slate-950">{svc.name}</span>
|
||||
<Badge variant="secondary" size="sm">{svc.type}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">ClusterIP {svc.clusterIP || "-"}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(svc.ports ?? []).map((port) => (
|
||||
<span key={`${port.name}-${port.port}`} className="rounded bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{port.name || "port"} {port.port}:{port.targetPort}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EventsTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
|
||||
<div className="space-y-3">
|
||||
{(data.events ?? []).length === 0 ? <EmptyLine text="No Kubernetes events matched this release." /> : null}
|
||||
{(data.events ?? []).map((event, index) => (
|
||||
<div key={`${event.involvedName}-${event.reason}-${index}`} className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={event.type === "Warning" ? "warning" : "secondary"} size="sm">{event.type || "Normal"}</Badge>
|
||||
<span className="font-semibold text-slate-950">{event.reason}</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{event.lastTimestamp ? new Date(event.lastTimestamp).toLocaleString() : ""}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{event.message}</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{event.involvedKind}/{event.involvedName} · count {event.count ?? 1}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const LogsTab = ({ data, combinedLogs, onCopy }: { data: InstanceDiagnosticsResponse; combinedLogs: string; onCopy: () => void }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="secondary" size="sm" icon={Copy} onClick={onCopy} disabled={!combinedLogs}>
|
||||
Copy Logs
|
||||
</Button>
|
||||
</div>
|
||||
{(data.logs ?? []).length === 0 ? <EmptyLine text="No pod logs were returned." /> : null}
|
||||
{(data.logs ?? []).map((entry) => (
|
||||
<div key={`${entry.pod}-${entry.container}`} className="overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-4 py-2 text-xs text-slate-300">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span className="font-mono">{entry.pod}/{entry.container}</span>
|
||||
</div>
|
||||
<pre className="max-h-96 overflow-auto p-4 text-xs leading-5 text-slate-100">{entry.error || entry.log || ""}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MetricCard = ({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: number }) => (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-500">{label}</span>
|
||||
<Icon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-semibold text-slate-950">{value}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyLine = ({ text }: { text: string }) => (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500">{text}</div>
|
||||
);
|
||||
@ -321,7 +321,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
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" },
|
||||
none: { color: "bg-slate-200/20 text-slate-500 border-gray-500/30", label: "No Data Available" },
|
||||
};
|
||||
|
||||
const badge = badges[source as keyof typeof badges] || badges.none;
|
||||
@ -335,11 +335,11 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
};
|
||||
|
||||
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 key={service.name || `service-${index}`} className="bg-slate-50 border border-slate-200 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>
|
||||
<h4 className="text-sm font-semibold text-slate-900">{service.name || `Service ${index + 1}`}</h4>
|
||||
<p className="text-xs text-slate-500 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'}
|
||||
@ -349,18 +349,18 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
<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 justify-between bg-slate-50 rounded p-2">
|
||||
<span className="text-xs text-slate-500">Cluster IP:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-white">{service.cluster_ip}</span>
|
||||
<span className="text-sm font-mono text-slate-900">{service.cluster_ip}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(service.cluster_ip!, "Cluster IP")}
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
className="p-1 hover:bg-slate-100 rounded transition"
|
||||
>
|
||||
{copiedText === service.cluster_ip ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
<Copy className="w-3 h-3 text-slate-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -369,10 +369,10 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
|
||||
{/* 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 key={idx} className="flex items-center justify-between bg-slate-50 rounded p-2">
|
||||
<span className="text-xs text-slate-500">{port.name || `Port ${idx + 1}`}:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-white">
|
||||
<span className="text-sm font-mono text-slate-900">
|
||||
{port.port} → {port.target_port} {port.protocol || 'TCP'}
|
||||
{port.node_port && ` (NodePort: ${port.node_port})`}
|
||||
</span>
|
||||
@ -386,7 +386,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
<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">
|
||||
<span className="text-sm font-mono text-slate-900">
|
||||
{ing.ip || ing.hostname}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -396,19 +396,19 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
href={`http://${ing.ip}:${service.ports?.[0]?.port || 80}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 hover:bg-gray-700 rounded transition"
|
||||
className="p-1 hover:bg-slate-100 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"
|
||||
className="p-1 hover:bg-slate-100 rounded transition"
|
||||
>
|
||||
{copiedText === ing.ip ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
<Copy className="w-3 h-3 text-slate-500" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@ -423,12 +423,12 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
);
|
||||
|
||||
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 key={ingress.name || `ingress-${index}`} className="bg-slate-50 border border-slate-200 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>
|
||||
<h4 className="text-sm font-semibold text-slate-900">{ingress.name || `Ingress ${index + 1}`}</h4>
|
||||
{ingress.class_name && (
|
||||
<p className="text-xs text-gray-400 mt-1">Class: {ingress.class_name}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Class: {ingress.class_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
@ -436,30 +436,30 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
|
||||
<div className="space-y-2">
|
||||
{ingress.rules?.map((rule, ruleIdx) => (
|
||||
<div key={ruleIdx} className="bg-gray-900/50 rounded p-3 space-y-2">
|
||||
<div key={ruleIdx} className="bg-slate-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>
|
||||
<span className="text-sm font-medium text-slate-900">{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"
|
||||
className="p-1 hover:bg-slate-100 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"
|
||||
className="p-1 hover:bg-slate-100 rounded transition"
|
||||
>
|
||||
{copiedText === host ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-400" />
|
||||
<Copy className="w-3 h-3 text-slate-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -470,7 +470,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
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">
|
||||
<div key={pathIdx} className="text-xs text-slate-500 ml-4">
|
||||
• {path.path || '/'} → {serviceName}:{servicePort}
|
||||
</div>
|
||||
);
|
||||
@ -489,20 +489,20 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
|
||||
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">
|
||||
<div className="bg-white border border-slate-200 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 className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Instance Entries</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Instance Entries</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{instance.name} ({instance.namespace})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition"
|
||||
className="p-2 hover:bg-white rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -511,14 +511,14 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
{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>
|
||||
<span className="ml-3 text-slate-500">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"
|
||||
className="mt-4 px-4 py-2 bg-white text-slate-900 rounded-lg hover:bg-slate-100 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@ -527,7 +527,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-slate-500">Data Source:</h3>
|
||||
{getSourceBadge(entries.source)}
|
||||
</div>
|
||||
|
||||
@ -536,7 +536,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Services ({entries.services.length})
|
||||
</h3>
|
||||
</div>
|
||||
@ -551,7 +551,7 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Ingresses ({entries.ingresses.length})
|
||||
</h3>
|
||||
</div>
|
||||
@ -564,9 +564,9 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
{/* 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">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-3">Helm Notes</h3>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<pre className="text-xs text-slate-700 whitespace-pre-wrap font-mono">
|
||||
{entries.notes}
|
||||
</pre>
|
||||
</div>
|
||||
@ -579,8 +579,8 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
!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>
|
||||
<p className="text-slate-500">No entries found for this instance</p>
|
||||
<p className="text-xs text-slate-500 mt-2">Data source: {entries.source || 'unknown'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -588,10 +588,10 @@ export const EntriesModal: React.FC<EntriesModalProps> = ({ instance, onClose })
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-700 flex justify-end">
|
||||
<div className="p-6 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
className="px-4 py-2 bg-white text-slate-900 rounded-lg hover:bg-slate-100 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
XCircle,
|
||||
Clock,
|
||||
Network,
|
||||
Activity,
|
||||
Box,
|
||||
Calendar,
|
||||
GitBranch,
|
||||
@ -29,6 +30,7 @@ interface InstanceCardProps {
|
||||
onTerminate: (instance: InstanceResponse) => void;
|
||||
onRefresh: (instance: InstanceResponse) => void;
|
||||
onViewEntries: (instance: InstanceResponse) => void;
|
||||
onViewDiagnostics: (instance: InstanceResponse) => void;
|
||||
}
|
||||
|
||||
type StatusVisual = {
|
||||
@ -99,16 +101,16 @@ const STATUS_INFO_MAP: Record<InstanceStatus, StatusVisual> = {
|
||||
},
|
||||
[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",
|
||||
color: "text-slate-700",
|
||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/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",
|
||||
color: "text-slate-700",
|
||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40",
|
||||
glow: "shadow-slate-500/20",
|
||||
label: "Unknown",
|
||||
defaultReason: "Awaiting next state update.",
|
||||
@ -136,6 +138,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
onTerminate,
|
||||
onRefresh,
|
||||
onViewEntries,
|
||||
onViewDiagnostics,
|
||||
}) => {
|
||||
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
|
||||
const statusInfo =
|
||||
@ -164,12 +167,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
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">
|
||||
<div className="group relative bg-gradient-to-br from-white via-white to-slate-50 border border-slate-200 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="relative px-6 py-5 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{/* Enhanced icon with glow effect */}
|
||||
@ -179,12 +182,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</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">
|
||||
<h3 className="text-xl font-bold text-slate-950 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">
|
||||
<Package className="w-4 h-4 text-slate-500" />
|
||||
<p className="text-sm text-slate-500 font-mono">
|
||||
{repository}
|
||||
</p>
|
||||
<span className="text-slate-600">•</span>
|
||||
@ -206,10 +209,10 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</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>
|
||||
<div className="mt-4 flex flex-col gap-1 text-sm text-slate-700">
|
||||
<span className="font-medium text-slate-700">{statusReason}</span>
|
||||
{lastOperationLabel && (
|
||||
<span className="text-xs uppercase tracking-wide text-slate-400">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
Operation: {lastOperationLabel}
|
||||
</span>
|
||||
)}
|
||||
@ -217,48 +220,48 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</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="relative px-6 py-5 space-y-4 bg-gradient-to-b from-white to-slate-50">
|
||||
<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="p-3 bg-white border border-slate-200 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>
|
||||
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Namespace</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
<p className="text-sm font-bold text-slate-900">
|
||||
{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="p-3 bg-white border border-slate-200 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>
|
||||
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Revision</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
<p className="text-sm font-bold text-slate-900">
|
||||
{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="col-span-2 p-3 bg-white border border-slate-200 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>
|
||||
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Repository</p>
|
||||
</div>
|
||||
<p className="text-sm font-mono text-white truncate" title={repository}>
|
||||
<p className="text-sm font-mono text-slate-900 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="col-span-2 p-3 bg-white border border-slate-200 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>
|
||||
<p className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Launched</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white">
|
||||
<p className="text-sm font-bold text-slate-900">
|
||||
{createdAtText}
|
||||
</p>
|
||||
</div>
|
||||
@ -267,7 +270,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
{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" />
|
||||
<AlertTriangle className="w-5 h-5 text-rose-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-rose-200">Last error</p>
|
||||
@ -278,47 +281,51 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</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">
|
||||
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-5">
|
||||
<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"
|
||||
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition-all duration-200 hover:border-slate-300 hover:bg-slate-100 hover:shadow-lg"
|
||||
title="Refresh status"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 group-hover/btn:rotate-180 transition-transform duration-500" />
|
||||
Refresh
|
||||
<span className="truncate">Refresh</span>
|
||||
</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"
|
||||
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-emerald-500/40 bg-emerald-50 px-3 py-2.5 text-sm font-semibold text-emerald-700 transition-all duration-200 hover:border-emerald-500/60 hover:bg-emerald-100 hover:shadow-lg"
|
||||
title="View service entries"
|
||||
>
|
||||
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
Entries
|
||||
<span className="truncate">Entries</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewDiagnostics(instance)}
|
||||
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2.5 text-sm font-semibold text-indigo-700 transition-all duration-200 hover:border-indigo-300 hover:bg-indigo-100 hover:shadow-lg"
|
||||
title="View describe, events, and pod logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
<span className="truncate">Diagnostics</span>
|
||||
</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"
|
||||
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-blue-500/40 bg-blue-50 px-3 py-2.5 text-sm font-semibold text-blue-700 transition-all duration-200 hover:border-blue-500/60 hover:bg-blue-100 hover:shadow-lg"
|
||||
title="Modify instance configuration"
|
||||
>
|
||||
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
|
||||
Modify
|
||||
<span className="truncate">Modify</span>
|
||||
</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"
|
||||
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-red-50 px-3 py-2.5 text-sm font-semibold text-rose-700 transition-all duration-200 hover:border-rose-500/60 hover:bg-rose-100 hover:shadow-lg"
|
||||
title="Terminate instance"
|
||||
>
|
||||
<StopCircle className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
||||
Terminate
|
||||
<span className="truncate">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
|
||||
import { getValuesSchema } from "@/api";
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
FormField,
|
||||
Input,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Badge,
|
||||
@ -35,8 +35,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
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);
|
||||
|
||||
@ -58,7 +56,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
? JSON.parse(instance.values)
|
||||
: instance.values;
|
||||
setFormValues(parsedValues);
|
||||
setValuesYaml(typeof parsedValues === 'object' ? JSON.stringify(parsedValues, null, 2) : String(parsedValues));
|
||||
setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues));
|
||||
} catch (err) {
|
||||
console.error('[ModifyModal] Failed to parse existing values:', err);
|
||||
setValuesYaml(String(instance.values) || "");
|
||||
@ -104,8 +102,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
|
||||
const handleFormValuesChange = (values: Record<string, any>) => {
|
||||
setFormValues(values);
|
||||
// Also update YAML representation
|
||||
setValuesYaml(JSON.stringify(values, null, 2));
|
||||
setValuesYaml(stringifyYaml(values));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -116,7 +113,9 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
try {
|
||||
const payload: UpdateInstanceRequest = {
|
||||
version: tag && tag !== instance.version ? tag : undefined,
|
||||
values: valuesYaml.trim() ? JSON.parse(valuesYaml) : undefined,
|
||||
description: description.trim() || undefined,
|
||||
values: valuesYaml.trim() ? parseValuesYaml(valuesYaml) : undefined,
|
||||
valuesYaml: valuesYaml.trim() || undefined,
|
||||
};
|
||||
|
||||
if (!instance.clusterId || !instance.id) {
|
||||
@ -128,8 +127,8 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
await onConfirm(instance.clusterId, instance.id, payload);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError("Invalid JSON/YAML values. Please fix the configuration.");
|
||||
if (err instanceof Error && err.message.includes("YAML")) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError((err as Error).message || "Failed to modify instance");
|
||||
}
|
||||
@ -144,7 +143,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
onClose={onClose}
|
||||
title={`Modify Instance - ${instance.name || "Unnamed"}`}
|
||||
icon={Settings}
|
||||
iconColor="text-blue-400"
|
||||
iconColor="text-blue-600"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
@ -175,15 +174,15 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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"}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-slate-700">
|
||||
<span className="font-medium text-slate-900">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 className="text-sm text-slate-700">
|
||||
<span className="font-medium text-slate-900">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 className="text-sm text-slate-700">
|
||||
<span className="font-medium text-slate-900">Repository:</span> {instance.repository || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -215,12 +214,13 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
{/* Values Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-200">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Configuration Values
|
||||
</label>
|
||||
{valuesSchema?.properties && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMethod('form')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
@ -232,6 +232,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputMethod('yaml')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
@ -265,25 +266,9 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
)}
|
||||
</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>
|
||||
<p className="text-xs text-slate-500">
|
||||
Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit.
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
@ -324,3 +309,14 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseValuesYaml = (source: string): Record<string, any> => {
|
||||
const parsed = parseYaml(source);
|
||||
if (parsed == null) {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Values YAML must be an object");
|
||||
}
|
||||
return parsed as Record<string, any>;
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ import { InstanceErrors, SuccessMessages, formatApiError } from "@/shared/utils"
|
||||
import { InstanceCard } from "../components/InstanceCard";
|
||||
import { ModifyModal } from "../components/ModifyModal";
|
||||
import { EntriesModal } from "../components/EntriesModal";
|
||||
import { DiagnosticsModal } from "../components/DiagnosticsModal";
|
||||
import { globalCache } from "@/shared/services/artifact-cache";
|
||||
|
||||
const AUTO_REFRESH_INTERVAL_MS = 30000;
|
||||
@ -47,6 +48,7 @@ const InstancesManagementPage: React.FC = () => {
|
||||
// Modals
|
||||
const [modifyInstance, setModifyInstance] = useState<Instance | null>(null);
|
||||
const [entriesInstance, setEntriesInstance] = useState<Instance | null>(null);
|
||||
const [diagnosticsInstance, setDiagnosticsInstance] = useState<Instance | null>(null);
|
||||
|
||||
// 核心数据加载函数 - 使用全局缓存
|
||||
const loadDataCore = useCallback(async (options: LoadDataOptions = {}) => {
|
||||
@ -225,6 +227,10 @@ const InstancesManagementPage: React.FC = () => {
|
||||
setEntriesInstance(instance);
|
||||
}, []);
|
||||
|
||||
const handleViewDiagnostics = useCallback((instance: Instance) => {
|
||||
setDiagnosticsInstance(instance);
|
||||
}, []);
|
||||
|
||||
const handleModifyConfirm = useCallback(async (
|
||||
clusterId: string,
|
||||
instanceId: string,
|
||||
@ -333,43 +339,40 @@ const InstancesManagementPage: React.FC = () => {
|
||||
<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 group overflow-hidden bg-white border border-blue-100 rounded-lg p-6 hover:border-blue-200 hover:shadow-md transition-all duration-300">
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-blue-700 uppercase tracking-wider mb-2">Total Instances</p>
|
||||
<p className="text-4xl font-bold text-slate-900">{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 className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<Package className="w-8 h-8 text-blue-600" />
|
||||
</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 group overflow-hidden bg-white border border-emerald-100 rounded-lg p-6 hover:border-emerald-200 hover:shadow-md transition-all duration-300">
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-emerald-700 uppercase tracking-wider mb-2">Clusters</p>
|
||||
<p className="text-4xl font-bold text-slate-900">{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 className="p-4 bg-emerald-50 rounded-lg border border-emerald-100">
|
||||
<Server className="w-8 h-8 text-emerald-600" />
|
||||
</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 group overflow-hidden bg-white border border-violet-100 rounded-lg p-6 hover:border-violet-200 hover:shadow-md transition-all duration-300">
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-violet-700 uppercase tracking-wider mb-2">Showing</p>
|
||||
<p className="text-4xl font-bold text-slate-900">{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 className="p-4 bg-violet-50 rounded-lg border border-violet-100">
|
||||
<Boxes className="w-8 h-8 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -379,13 +382,13 @@ const InstancesManagementPage: React.FC = () => {
|
||||
|
||||
{/* 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="mb-6 p-5 bg-white border border-slate-200 rounded-lg shadow-sm">
|
||||
<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">
|
||||
<label className="text-sm font-semibold text-slate-700">
|
||||
Filter by Cluster:
|
||||
</label>
|
||||
</div>
|
||||
@ -441,10 +444,10 @@ const InstancesManagementPage: React.FC = () => {
|
||||
<Server className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
{cluster.name || "Unnamed Cluster"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-0.5">
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{instances.length} {instances.length === 1 ? 'instance' : 'instances'} running
|
||||
</p>
|
||||
</div>
|
||||
@ -458,6 +461,7 @@ const InstancesManagementPage: React.FC = () => {
|
||||
onTerminate={handleTerminate}
|
||||
onRefresh={handleRefresh}
|
||||
onViewEntries={handleViewEntries}
|
||||
onViewDiagnostics={handleViewDiagnostics}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -474,6 +478,7 @@ const InstancesManagementPage: React.FC = () => {
|
||||
onTerminate={handleTerminate}
|
||||
onRefresh={handleRefresh}
|
||||
onViewEntries={handleViewEntries}
|
||||
onViewDiagnostics={handleViewDiagnostics}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -497,6 +502,13 @@ const InstancesManagementPage: React.FC = () => {
|
||||
onClose={() => setEntriesInstance(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{diagnosticsInstance && (
|
||||
<DiagnosticsModal
|
||||
instance={diagnosticsInstance}
|
||||
onClose={() => setDiagnosticsInstance(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,10 +4,13 @@
|
||||
* Supports Values Schema for dynamic form generation
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Rocket, AlertCircle, FileCode, FormInput } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Rocket, AlertCircle, FileCode, FormInput, Sparkles, SlidersHorizontal } from "lucide-react";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { useToast } from "@/shared";
|
||||
import { createInstance, listClusters, getValuesSchema } from "@/api";
|
||||
import { createInstance, listClusters, getValuesSchema, getValuesYaml } from "@/api";
|
||||
import type { CreateInstanceRequest, ClusterResponse } from "@/api";
|
||||
import { useAuth } from "@/app/providers";
|
||||
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
|
||||
import {
|
||||
Modal,
|
||||
@ -23,6 +26,24 @@ import {
|
||||
import type { ArtifactCategory } from "../utils/artifactType";
|
||||
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
|
||||
|
||||
type NamespacePolicyValue =
|
||||
| string
|
||||
| {
|
||||
allowedNamespaces?: string[];
|
||||
defaultNamespace?: string;
|
||||
namespace?: string;
|
||||
readOnly?: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
type ClusterWithNamespacePolicy = ClusterResponse & {
|
||||
allowedNamespaces?: string[];
|
||||
namespacePolicy?: NamespacePolicyValue;
|
||||
namespaceReadOnly?: boolean;
|
||||
namespaceReadonly?: boolean;
|
||||
defaultNamespace?: string;
|
||||
};
|
||||
|
||||
interface LaunchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@ -40,29 +61,52 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
tag,
|
||||
artifactType,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
|
||||
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
|
||||
const [clusters, setClusters] = useState<ClusterWithNamespacePolicy[]>([]);
|
||||
const [loadingClusters, setLoadingClusters] = useState(false);
|
||||
|
||||
// Form fields
|
||||
const [clusterId, setClusterId] = useState("");
|
||||
const [namespace, setNamespace] = useState("default");
|
||||
const [namespace, setNamespace] = useState(user?.namespace || "default");
|
||||
const [instanceName, setInstanceName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Values Schema support
|
||||
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
|
||||
const [defaultValuesYaml, setDefaultValuesYaml] = useState("");
|
||||
const [loadingSchema, setLoadingSchema] = useState(false);
|
||||
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
|
||||
const [inputMethod, setInputMethod] = useState<'quick' | 'form' | 'yaml'>('quick');
|
||||
const [valuesForm, setValuesForm] = useState<Record<string, any>>({});
|
||||
const [valuesYaml, setValuesYaml] = useState("");
|
||||
const [yamlError, setYamlError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const schemaDefaults = React.useMemo(
|
||||
() => valuesSchema ? extractSchemaDefaults(valuesSchema) : {},
|
||||
[valuesSchema]
|
||||
);
|
||||
const schemaDefaultCount = React.useMemo(
|
||||
() => countLeafValues(schemaDefaults),
|
||||
[schemaDefaults]
|
||||
);
|
||||
const isVllmChart = /(^|[/_-])vllm([/_-]|$)|vllm-serve/i.test(repositoryName);
|
||||
const selectedCluster = React.useMemo(
|
||||
() => clusters.find((cluster) => cluster.id === clusterId),
|
||||
[clusters, clusterId]
|
||||
);
|
||||
const namespaceAccess = React.useMemo(
|
||||
() => getNamespaceAccess(selectedCluster, user?.namespace),
|
||||
[selectedCluster, user?.namespace]
|
||||
);
|
||||
|
||||
// Load clusters and schema on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadClusters();
|
||||
loadValuesSchema();
|
||||
loadDefaultValuesYaml();
|
||||
}
|
||||
}, [isOpen, registryId, repositoryName, tag]);
|
||||
|
||||
@ -70,10 +114,13 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
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);
|
||||
const normalizedClusters = data as ClusterWithNamespacePolicy[];
|
||||
setClusters(normalizedClusters);
|
||||
const preferredCluster =
|
||||
normalizedClusters.find((cluster) => cluster.id && cluster.id === user?.defaultClusterId) ??
|
||||
normalizedClusters.find((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
|
||||
if (preferredCluster?.id) {
|
||||
setClusterId(preferredCluster.id);
|
||||
} else {
|
||||
setClusterId("");
|
||||
}
|
||||
@ -85,6 +132,16 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const loadDefaultValuesYaml = async () => {
|
||||
try {
|
||||
const response = await getValuesYaml({ registryId, repositoryName, reference: tag });
|
||||
setDefaultValuesYaml(typeof response.valuesYaml === "string" ? response.valuesYaml : "");
|
||||
} catch (err) {
|
||||
console.error("[LaunchModal] Failed to load chart values.yaml:", err);
|
||||
setDefaultValuesYaml("");
|
||||
}
|
||||
};
|
||||
|
||||
const loadValuesSchema = async () => {
|
||||
setLoadingSchema(true);
|
||||
try {
|
||||
@ -93,16 +150,13 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
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');
|
||||
console.log('[LaunchModal] No values schema available; keeping quick launch with optional YAML overrides');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LaunchModal] Failed to load values schema:', err);
|
||||
setValuesSchema(null);
|
||||
setInputMethod('yaml');
|
||||
} finally {
|
||||
setLoadingSchema(false);
|
||||
}
|
||||
@ -112,13 +166,32 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
setInstanceName("");
|
||||
setDescription("");
|
||||
setValuesYaml("");
|
||||
setYamlError(null);
|
||||
setValuesForm({});
|
||||
setNamespace("default");
|
||||
setNamespace(user?.namespace || "default");
|
||||
setInputMethod("quick");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
useEffect(() => {
|
||||
if (!selectedCluster) {
|
||||
return;
|
||||
}
|
||||
const access = getNamespaceAccess(selectedCluster, user?.namespace);
|
||||
if (access.defaultNamespace && namespace !== access.defaultNamespace) {
|
||||
setNamespace(access.defaultNamespace);
|
||||
} else if (!access.defaultNamespace && user?.namespace && namespace !== user.namespace) {
|
||||
setNamespace(user.namespace);
|
||||
}
|
||||
}, [selectedCluster, namespace, user?.namespace]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (artifactType !== "chart") {
|
||||
toastError("Only Helm chart artifacts can be launched.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clusterId) {
|
||||
toastError(ValidationErrors.REQUIRED_FIELD("Cluster"));
|
||||
return;
|
||||
@ -133,15 +206,27 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
toastError(ValidationErrors.REQUIRED_FIELD("Namespace"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
namespaceAccess.allowedNamespaces.length > 0 &&
|
||||
!namespaceAccess.allowedNamespaces.includes(namespace.trim())
|
||||
) {
|
||||
toastError("Selected namespace is not allowed for this cluster.");
|
||||
return;
|
||||
}
|
||||
|
||||
let valuesObj: Record<string, any> = {};
|
||||
let normalizedValuesYaml = "";
|
||||
if (inputMethod === "form" && Object.keys(valuesForm).length > 0) {
|
||||
valuesObj = valuesForm;
|
||||
} else if (valuesYaml.trim()) {
|
||||
valuesObj = pruneEmptyValues(valuesForm);
|
||||
} else if (inputMethod === "yaml" && valuesYaml.trim()) {
|
||||
try {
|
||||
valuesObj = JSON.parse(valuesYaml.trim());
|
||||
} catch {
|
||||
toastError("Invalid YAML format. Please check your values.");
|
||||
valuesObj = parseValuesYaml(valuesYaml);
|
||||
normalizedValuesYaml = valuesYaml.trim();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const message = "Invalid YAML format. Please check your values.";
|
||||
setYamlError(message);
|
||||
toastError(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -152,7 +237,9 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
registryId,
|
||||
repository: repositoryName,
|
||||
tag,
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
...(Object.keys(valuesObj).length > 0 ? { values: valuesObj } : {}),
|
||||
...(normalizedValuesYaml ? { valuesYaml: normalizedValuesYaml } : {}),
|
||||
};
|
||||
|
||||
toastInfo("Launching instance...", {
|
||||
@ -161,17 +248,19 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
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);
|
||||
});
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createInstance({ clusterId }, request);
|
||||
success(SuccessMessages.INSTANCE_DEPLOYED);
|
||||
resetFormState();
|
||||
onClose();
|
||||
navigate("/artifact/instances");
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || InstanceErrors.DEPLOY_FAILED);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
@ -198,7 +287,8 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
variant="success"
|
||||
icon={Rocket}
|
||||
onClick={handleSubmit}
|
||||
disabled={clusters.length === 0}
|
||||
loading={submitting}
|
||||
disabled={clusters.length === 0 || artifactType !== "chart" || submitting || Boolean(yamlError)}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
@ -206,7 +296,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
}
|
||||
>
|
||||
<div className="space-y-1 mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-slate-500">
|
||||
{repositoryName}:{tag}
|
||||
</p>
|
||||
</div>
|
||||
@ -215,9 +305,9 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
{/* Cluster Selection */}
|
||||
<FormField label="Target Cluster" required>
|
||||
{loadingClusters ? (
|
||||
<div className="text-sm text-gray-500">Loading clusters...</div>
|
||||
<div className="text-sm text-slate-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">
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>No clusters available. Please add a cluster first.</span>
|
||||
</div>
|
||||
@ -254,13 +344,33 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
|
||||
{/* Namespace */}
|
||||
<FormField label="Namespace" required>
|
||||
<Input
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder="default"
|
||||
required
|
||||
/>
|
||||
{namespaceAccess.allowedNamespaces.length > 0 ? (
|
||||
<DropdownSelect
|
||||
value={namespace}
|
||||
onChange={(value) => setNamespace(value)}
|
||||
options={namespaceAccess.allowedNamespaces.map((allowedNamespace) => ({
|
||||
value: allowedNamespace,
|
||||
label: allowedNamespace,
|
||||
}))}
|
||||
placeholder="Select a namespace"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
placeholder="default"
|
||||
required
|
||||
disabled={namespaceAccess.readOnly}
|
||||
/>
|
||||
)}
|
||||
{namespaceAccess.readOnly && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Namespace is controlled by your workspace policy.
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* Description */}
|
||||
@ -275,83 +385,174 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
|
||||
{/* Values Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
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 className="flex gap-1 rounded-md border border-slate-200 bg-slate-50 p-1">
|
||||
<ModeButton active={inputMethod === "quick"} onClick={() => setInputMethod("quick")} icon={Sparkles} label="Quick" />
|
||||
<ModeButton active={inputMethod === "form"} onClick={() => setInputMethod("form")} icon={FormInput} label="Guided" disabled={!valuesSchema?.properties} />
|
||||
<ModeButton active={inputMethod === "yaml"} onClick={() => setInputMethod("yaml")} icon={FileCode} label="YAML" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSchema ? (
|
||||
<LoadingState message="Loading configuration schema..." size="sm" />
|
||||
) : inputMethod === 'quick' ? (
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50/70 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="mt-0.5 h-5 w-5 text-blue-600" />
|
||||
<div className="space-y-2 text-sm text-slate-700">
|
||||
<p className="font-medium text-slate-900">
|
||||
Quick launch uses the chart defaults.
|
||||
</p>
|
||||
<p>
|
||||
{isVllmChart
|
||||
? "For vLLM charts, this keeps the one-click path stable while image/model/resource overrides stay in Guided or YAML."
|
||||
: "Use Guided or YAML only when this release needs explicit overrides."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant="info" size="sm">No values override</Badge>
|
||||
{defaultValuesYaml && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
Chart values.yaml available
|
||||
</Badge>
|
||||
)}
|
||||
{valuesSchema?.properties && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{schemaDefaultCount} schema defaults detected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{defaultValuesYaml && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={FileCode}
|
||||
onClick={() => {
|
||||
setValuesYaml(defaultValuesYaml);
|
||||
setYamlError(null);
|
||||
setInputMethod("yaml");
|
||||
}}
|
||||
>
|
||||
Load Defaults from values.yaml
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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 className="space-y-3">
|
||||
{schemaDefaultCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={SlidersHorizontal}
|
||||
onClick={() => setValuesForm(schemaDefaults)}
|
||||
>
|
||||
Load Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="border border-slate-200 rounded-lg p-4 max-h-96 overflow-y-auto bg-slate-50">
|
||||
<SchemaFormGenerator
|
||||
schema={valuesSchema}
|
||||
values={valuesForm}
|
||||
onChange={setValuesForm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FormField
|
||||
help={valuesSchema
|
||||
? "Optional: Override default values with custom YAML configuration"
|
||||
? "Optional: advanced YAML overrides. Invalid YAML is blocked before submit."
|
||||
: "Optional: Chart does not provide a schema. Enter YAML configuration manually."
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap justify-end gap-2">
|
||||
{defaultValuesYaml && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={FileCode}
|
||||
onClick={() => {
|
||||
setValuesYaml(defaultValuesYaml);
|
||||
setYamlError(null);
|
||||
}}
|
||||
>
|
||||
Load Defaults from values.yaml
|
||||
</Button>
|
||||
)}
|
||||
{!defaultValuesYaml && schemaDefaultCount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={SlidersHorizontal}
|
||||
onClick={() => {
|
||||
setValuesYaml(stringifyYaml(schemaDefaults));
|
||||
setYamlError(null);
|
||||
}}
|
||||
>
|
||||
Load Schema Defaults
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setValuesYaml("");
|
||||
setYamlError(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={valuesYaml}
|
||||
onChange={(e) => setValuesYaml(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setValuesYaml(next);
|
||||
if (!next.trim()) {
|
||||
setYamlError(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
parseValuesYaml(next);
|
||||
setYamlError(null);
|
||||
} catch (err) {
|
||||
setYamlError(err instanceof Error ? err.message : "Invalid YAML");
|
||||
}
|
||||
}}
|
||||
placeholder="# Enter custom values in YAML format # Example: # replicaCount: 3 # image: # repository: myapp # tag: latest"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
error={yamlError || undefined}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact Info */}
|
||||
<div className="p-4 bg-gray-800/50 border border-gray-700 rounded-lg space-y-2">
|
||||
<div className="p-4 bg-slate-50 border border-slate-200 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>
|
||||
<span className="text-slate-500">Repository:</span>
|
||||
<span className="text-slate-900 font-mono">{repositoryName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Tag:</span>
|
||||
<span className="text-slate-500">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>
|
||||
<span className="text-slate-500">Type:</span>
|
||||
<span className="text-slate-900">{artifactType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@ -362,6 +563,28 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const ModeButton: React.FC<{
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}> = ({ active, disabled = false, icon: Icon, label, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-2 rounded px-3 py-1.5 text-xs font-medium transition ${
|
||||
active
|
||||
? "bg-white text-blue-700 shadow-sm ring-1 ring-slate-200"
|
||||
: "text-slate-500 hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
|
||||
if (schemaResponse == null) {
|
||||
return null;
|
||||
@ -394,3 +617,111 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNamespaceAccess = (cluster?: ClusterWithNamespacePolicy, userNamespace?: string) => {
|
||||
const policy = cluster?.namespacePolicy;
|
||||
const policyObject = isRecord(policy) ? policy : undefined;
|
||||
const allowedNamespaces = uniqueStrings([
|
||||
...(Array.isArray(cluster?.allowedNamespaces) ? cluster.allowedNamespaces : []),
|
||||
...(Array.isArray(policyObject?.allowedNamespaces) ? policyObject.allowedNamespaces : []),
|
||||
]);
|
||||
const defaultNamespace =
|
||||
firstString(cluster?.defaultNamespace, policyObject?.defaultNamespace, policyObject?.namespace) ??
|
||||
allowedNamespaces[0] ??
|
||||
userNamespace ??
|
||||
"default";
|
||||
const policyName = typeof policy === "string" ? policy.toLowerCase() : "";
|
||||
const readOnly =
|
||||
cluster?.namespaceReadOnly === true ||
|
||||
cluster?.namespaceReadonly === true ||
|
||||
policyObject?.readOnly === true ||
|
||||
policyObject?.readonly === true ||
|
||||
["readonly", "read_only", "allowed_only", "restricted"].includes(policyName) ||
|
||||
allowedNamespaces.length > 0;
|
||||
|
||||
return {
|
||||
allowedNamespaces,
|
||||
defaultNamespace,
|
||||
readOnly,
|
||||
};
|
||||
};
|
||||
|
||||
const uniqueStrings = (values: unknown[]): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
values.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const firstString = (...values: unknown[]): string | undefined =>
|
||||
values.find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
|
||||
const parseValuesYaml = (source: string): Record<string, any> => {
|
||||
const parsed = parseYaml(source);
|
||||
if (parsed == null) {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Values YAML must be an object");
|
||||
}
|
||||
return parsed as Record<string, any>;
|
||||
};
|
||||
|
||||
const extractSchemaDefaults = (schema: JsonSchema): Record<string, any> => {
|
||||
if (schema.default !== undefined && isRecord(schema.default)) {
|
||||
return schema.default as Record<string, any>;
|
||||
}
|
||||
|
||||
const output: Record<string, any> = {};
|
||||
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
||||
const defaultValue = defaultForSchema(property);
|
||||
if (defaultValue !== undefined) {
|
||||
output[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const defaultForSchema = (schema: JsonSchema): unknown => {
|
||||
if (schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
const nested: Record<string, unknown> = {};
|
||||
for (const [key, property] of Object.entries(schema.properties)) {
|
||||
const defaultValue = defaultForSchema(property);
|
||||
if (defaultValue !== undefined) {
|
||||
nested[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
return Object.keys(nested).length > 0 ? nested : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const pruneEmptyValues = (value: Record<string, any>): Record<string, any> => {
|
||||
const output: Record<string, any> = {};
|
||||
for (const [key, fieldValue] of Object.entries(value)) {
|
||||
if (isRecord(fieldValue)) {
|
||||
const nested = pruneEmptyValues(fieldValue as Record<string, any>);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
output[key] = nested;
|
||||
}
|
||||
} else if (fieldValue !== "" && fieldValue !== undefined && fieldValue !== null) {
|
||||
output[key] = fieldValue;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const countLeafValues = (value: unknown): number => {
|
||||
if (!isRecord(value)) {
|
||||
return value === undefined ? 0 : 1;
|
||||
}
|
||||
return Object.values(value).reduce<number>((sum, item) => sum + countLeafValues(item), 0);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* Simple card for displaying a single tag/artifact
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Package, Rocket, Copy, HardDrive } from "lucide-react";
|
||||
import { Box, Copy, File, HardDrive, Package, Rocket } from "lucide-react";
|
||||
import { LaunchModal } from "./LaunchModal";
|
||||
import { useToast } from "@/shared";
|
||||
import type { ArtifactListItem } from "@/api";
|
||||
@ -11,22 +11,30 @@ import { inferArtifactCategory, type ArtifactCategory } from "../utils/artifactT
|
||||
|
||||
interface TagCardProps {
|
||||
registryId: string;
|
||||
registryUrl?: string;
|
||||
tag: ArtifactListItem;
|
||||
}
|
||||
|
||||
export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag }) => {
|
||||
const { success } = useToast();
|
||||
const [launchModalOpen, setLaunchModalOpen] = useState(false);
|
||||
const category = inferArtifactCategory(tag);
|
||||
|
||||
const handleLaunch = () => {
|
||||
if (category !== "chart") {
|
||||
return;
|
||||
}
|
||||
setLaunchModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const tagName = tag.tag || '';
|
||||
if (!tagName || !tag.repositoryName) return;
|
||||
const pullCommand = `helm pull oci://${tag.repositoryName}:${tagName}`;
|
||||
const registryHost = normalizeRegistryHost(registryUrl);
|
||||
const repositoryPath = registryHost
|
||||
? `${registryHost}/${tag.repositoryName}`
|
||||
: tag.repositoryName;
|
||||
const pullCommand = `helm pull oci://${repositoryPath} --version ${tagName}`;
|
||||
navigator.clipboard.writeText(pullCommand);
|
||||
success("Pull command copied to clipboard!");
|
||||
};
|
||||
@ -47,24 +55,25 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
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";
|
||||
return "text-slate-500 bg-gray-500/10 border-gray-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: ArtifactCategory) => {
|
||||
const className = "w-5 h-5";
|
||||
switch (type) {
|
||||
case "chart":
|
||||
return "📦";
|
||||
return <Package className={className} />;
|
||||
case "image":
|
||||
return "🐳";
|
||||
return <Box className={className} />;
|
||||
default:
|
||||
return "📄";
|
||||
return <File className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-dark-card border border-dark-border rounded-lg p-4 hover:border-brand-blue/50 transition-all group">
|
||||
<div className="bg-white border border-slate-200 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">
|
||||
@ -78,8 +87,8 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
<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">
|
||||
<Package className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-slate-900 truncate">
|
||||
{tag.tag || 'N/A'}
|
||||
</h3>
|
||||
<span
|
||||
@ -91,12 +100,12 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
</div>
|
||||
|
||||
{/* Repository path */}
|
||||
<p className="text-xs text-gray-500 truncate mb-2">
|
||||
<p className="text-xs text-slate-500 truncate mb-2">
|
||||
{tag.repositoryName}
|
||||
</p>
|
||||
|
||||
{/* Size */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<HardDrive className="w-3.5 h-3.5" />
|
||||
<span>{formatSize(tag.size || 0)}</span>
|
||||
</div>
|
||||
@ -104,20 +113,22 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
|
||||
{/* 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>
|
||||
|
||||
{category === "chart" && (
|
||||
<button
|
||||
onClick={handleLaunch}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded
|
||||
text-xs font-medium transition-colors flex items-center gap-1.5"
|
||||
title="Launch this Helm chart"
|
||||
>
|
||||
<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"
|
||||
className="px-3 py-1.5 bg-white hover:bg-slate-50 text-slate-700
|
||||
border border-slate-200 rounded text-xs transition-colors flex items-center gap-1.5"
|
||||
title="Copy pull command"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
@ -142,3 +153,11 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, tag }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeRegistryHost = (url?: string) => {
|
||||
if (!url) return "";
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,10 +48,8 @@ interface RegistryNode {
|
||||
}
|
||||
|
||||
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" },
|
||||
{ value: undefined, label: "All tags" },
|
||||
];
|
||||
|
||||
const ArtifactBrowserPage: React.FC = () => {
|
||||
@ -67,7 +65,7 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
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 [filter, setFilter] = useState<ListArtifactsFilter | undefined>("chart");
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
@ -143,7 +141,7 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
try {
|
||||
let repoNodes = globalCache.get<RepositoryNode[]>("repositories", registryId);
|
||||
if (!repoNodes) {
|
||||
const response = await listRepositories({ registryId });
|
||||
const response = await listRepositories({ registryId }, { artifactType: "chart" });
|
||||
repoNodes = normalizeRepositories(registry, response);
|
||||
globalCache.set("repositories", repoNodes, registryId);
|
||||
}
|
||||
@ -255,17 +253,21 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
|
||||
.name
|
||||
: null;
|
||||
const selectedRegistryUrl = selectedRepository
|
||||
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
|
||||
.url
|
||||
: undefined;
|
||||
|
||||
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-shrink-0 border-b border-slate-200 bg-white 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" />
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Artifact Browser</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Browse registries, repositories, and artifacts
|
||||
<h1 className="text-xl font-semibold text-slate-900">Chart Browser</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Select a Harbor chart and launch it into a Kubernetes cluster
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -280,23 +282,23 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
</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="flex-1 flex overflow-hidden bg-slate-50">
|
||||
<aside className="w-80 border-r border-slate-200 bg-white flex flex-col">
|
||||
<div className="p-4 border-b border-slate-200 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" />
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<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"
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{repositoryError && (
|
||||
<p className="text-xs text-red-400">{repositoryError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Registries</span>
|
||||
<Badge variant="secondary">{registryNodes.length}</Badge>
|
||||
</div>
|
||||
@ -312,7 +314,7 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No registries"
|
||||
description="Add a registry to get started."
|
||||
description="Add a Harbor registry to browse deployable charts."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -320,18 +322,18 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
<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"
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{node.expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
<ChevronDown className="w-4 h-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
<ChevronRight className="w-4 h-4 text-slate-500" />
|
||||
)}
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<Database className="w-4 h-4 text-blue-600" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm text-white">{node.registry.name || "Unnamed"}</p>
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
<p className="text-sm text-slate-900">{node.registry.name || "Unnamed"}</p>
|
||||
<p className="text-[11px] text-slate-500 truncate">
|
||||
{node.registry.url}
|
||||
</p>
|
||||
</div>
|
||||
@ -339,12 +341,12 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
<Badge variant="secondary">{node.repositories.length}</Badge>
|
||||
</button>
|
||||
{node.expanded && (
|
||||
<div className="bg-gray-900/60">
|
||||
<div className="bg-slate-50/60">
|
||||
{node.repositories.length === 0 ? (
|
||||
<p className="px-8 py-3 text-xs text-gray-500">
|
||||
<p className="px-8 py-3 text-xs text-slate-500">
|
||||
{loadingRepositories
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."}
|
||||
: "No chart repositories found."}
|
||||
</p>
|
||||
) : (
|
||||
node.repositories.map((repo) => {
|
||||
@ -357,13 +359,13 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
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"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "hover:bg-white/80 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{repo.name}</span>
|
||||
{repo.artifactCount !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-slate-500">
|
||||
{repo.artifactCount}
|
||||
</span>
|
||||
)}
|
||||
@ -379,38 +381,38 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col bg-dark-card overflow-hidden">
|
||||
<main className="flex-1 flex flex-col bg-white 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."
|
||||
description="Choose a chart repository from the left panel."
|
||||
/>
|
||||
</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-shrink-0 border-b border-slate-200 p-5 bg-slate-50">
|
||||
<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">
|
||||
<p className="text-xs uppercase text-slate-500">Chart repository</p>
|
||||
<h2 className="text-2xl font-semibold text-slate-900">
|
||||
{selectedRepository.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-slate-500">
|
||||
{selectedRegistryName || selectedRepository.registryId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<Filter className="w-4 h-4 text-slate-500" />
|
||||
{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"
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "border-slate-200 text-slate-700 hover:border-slate-400"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
@ -432,8 +434,8 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
title="No artifacts"
|
||||
description={
|
||||
filter
|
||||
? `No ${filter} artifacts found for this repository.`
|
||||
: "This repository doesn't contain any artifacts yet."
|
||||
? `No ${filter} tags found for this repository.`
|
||||
: "This repository doesn't contain any tagged artifacts yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@ -442,6 +444,7 @@ const ArtifactBrowserPage: React.FC = () => {
|
||||
<TagCard
|
||||
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
||||
registryId={selectedRepository.registryId}
|
||||
registryUrl={selectedRegistryUrl}
|
||||
tag={artifact}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* Display all registries and their repositories
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Database, RefreshCw, Plus, Package } from "lucide-react";
|
||||
import { Database, RefreshCw, Plus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "@/shared";
|
||||
import {
|
||||
@ -176,63 +176,6 @@ const RegistriesBrowserPage: React.FC = () => {
|
||||
{!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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LogIn, UserPlus, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import { LogIn, Loader2, ShieldCheck } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { getErrorMessage } from "@/shared/utils/handleApiError";
|
||||
import { login as apiLogin, register as apiRegister, type AuthResponse } from "@/api";
|
||||
import { login as apiLogin, type AuthResponse } from "@/api";
|
||||
|
||||
type Props = {
|
||||
onLogin: (response: AuthResponse) => void;
|
||||
@ -13,23 +13,12 @@ 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();
|
||||
@ -59,115 +48,36 @@ const AuthPage: React.FC<Props> = ({ onLogin }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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="relative min-h-screen bg-slate-50 text-slate-900 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">
|
||||
<div className="relative w-full max-w-md p-6 sm:p-7 bg-white/95 border border-slate-200 rounded-lg shadow-2xl backdrop-blur-xl">
|
||||
<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>
|
||||
<ShieldCheck className="w-11 h-11 text-blue-600 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-semibold text-slate-900">OCDP Console</h1>
|
||||
<p className="text-slate-600 text-sm mt-1">Sign in with an account created by an administrator</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Username</label>
|
||||
<label className="block text-sm text-slate-600">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"
|
||||
className="mt-1 w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-600 focus:outline-none transition-shadow"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Password</label>
|
||||
<label className="block text-sm text-slate-600">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"
|
||||
className="mt-1 w-full bg-white border border-slate-200 rounded-lg p-2 text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-600 focus:outline-none transition-shadow"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
@ -177,7 +87,7 @@ const AuthPage: React.FC<Props> = ({ onLogin }) => {
|
||||
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 ? "bg-blue-500 cursor-wait text-white" : "bg-blue-600 text-white hover:bg-blue-700"}`}
|
||||
>
|
||||
{loginLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogIn className="w-4 h-4" />}
|
||||
{loginLoading ? "Logging in..." : "Login"}
|
||||
@ -185,78 +95,7 @@ const AuthPage: React.FC<Props> = ({ onLogin }) => {
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -78,15 +78,21 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
newErrors.host = ValidationErrors.INVALID_URL;
|
||||
}
|
||||
|
||||
// 创建模式:必填证书
|
||||
// 创建模式:需要 token 或完整证书三件套
|
||||
if (!cluster) {
|
||||
if (!formData.caData.trim()) {
|
||||
const hasToken = Boolean(formData.token.trim());
|
||||
const hasCertAuth = Boolean(
|
||||
formData.caData.trim() &&
|
||||
formData.certData.trim() &&
|
||||
formData.keyData.trim()
|
||||
);
|
||||
if (!hasToken && !hasCertAuth && !formData.caData.trim()) {
|
||||
newErrors.caData = ValidationErrors.REQUIRED_FIELD("CA Certificate");
|
||||
}
|
||||
if (!formData.certData.trim()) {
|
||||
if (!hasToken && !hasCertAuth && !formData.certData.trim()) {
|
||||
newErrors.certData = ValidationErrors.REQUIRED_FIELD("Client Certificate");
|
||||
}
|
||||
if (!formData.keyData.trim()) {
|
||||
if (!hasToken && !hasCertAuth && !formData.keyData.trim()) {
|
||||
newErrors.keyData = ValidationErrors.REQUIRED_FIELD("Client Key");
|
||||
}
|
||||
}
|
||||
@ -115,6 +121,9 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
if (newCaData.trim()) submitData.caData = newCaData.trim();
|
||||
if (newCertData.trim()) submitData.certData = newCertData.trim();
|
||||
if (newKeyData.trim()) submitData.keyData = newKeyData.trim();
|
||||
if (formData.token.trim() && formData.token.trim() !== (cluster.token ?? "")) {
|
||||
submitData.token = formData.token.trim();
|
||||
}
|
||||
} else {
|
||||
// 创建模式:发送所有必填字段(使用 camelCase)
|
||||
submitData.caData = formData.caData.trim();
|
||||
@ -131,7 +140,7 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
<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">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
@ -139,9 +148,9 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.name ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none`}
|
||||
placeholder="e.g., Production Cluster"
|
||||
/>
|
||||
{errors.name && (
|
||||
@ -151,7 +160,7 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* API Server URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
@ -159,31 +168,31 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.host ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-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">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -195,10 +204,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
@ -209,15 +218,15 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.caData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Cluster CA certificate in base64 format (certificate-authority-data)
|
||||
</p>
|
||||
</>
|
||||
@ -226,16 +235,16 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* Client Certificate */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -247,10 +256,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新证书以覆盖,留空则保持原证书不变
|
||||
</p>
|
||||
</>
|
||||
@ -260,15 +269,15 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.certData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Client certificate in base64 format (client-certificate-data)
|
||||
</p>
|
||||
</>
|
||||
@ -277,16 +286,16 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
|
||||
{/* Client Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5 flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -298,10 +307,10 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 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 className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新密钥以覆盖,留空则保持原密钥不变
|
||||
</p>
|
||||
</>
|
||||
@ -311,42 +320,57 @@ export const ClusterForm: React.FC<ClusterFormProps> = ({
|
||||
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`}
|
||||
className={`w-full bg-white border ${
|
||||
errors.keyData ? "border-red-500" : "border-slate-300"
|
||||
} rounded-lg p-2.5 sm:p-3 text-slate-900 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">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Client private key in base64 format (client-key-data)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bearer Token */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5 flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-blue-600" />
|
||||
Bearer Token {!cluster && <span className="text-slate-400 text-xs">(optional alternative to client certs)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.token}
|
||||
onChange={(e) => handleChange("token", e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-slate-900 placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs sm:text-sm resize-none"
|
||||
placeholder="Paste service-account bearer token"
|
||||
/>
|
||||
</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" />
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-500" />
|
||||
{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"
|
||||
className="w-full bg-white border border-slate-300 rounded-lg p-2.5 sm:p-3 text-sm sm:text-base text-slate-900 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">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-3 sm:pt-4 border-t border-slate-200">
|
||||
<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"
|
||||
className="flex-1 px-4 py-2.5 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition text-sm sm:text-base"
|
||||
>
|
||||
{ButtonText.CANCEL}
|
||||
</button>
|
||||
|
||||
@ -3,15 +3,19 @@
|
||||
* Display cluster list with edit and delete actions
|
||||
*/
|
||||
import React from "react";
|
||||
import { Server, Edit, Trash2, Key, ExternalLink } from "lucide-react";
|
||||
import { Server, Edit, Trash2, Key, ExternalLink, Activity } from "lucide-react";
|
||||
import type { ClusterConfig } from "@/core/types";
|
||||
import { LoadingText, EmptyText } from "@/shared/constants";
|
||||
import type { User } from "@/app/providers/AuthContext";
|
||||
import { canUseResourceAction, getVisibilityLabel, type ResourceWithAccess } from "@/app/providers/auth-model";
|
||||
|
||||
interface ClusterListProps {
|
||||
clusters: ClusterConfig[];
|
||||
loading: boolean;
|
||||
onEdit: (cluster: ClusterConfig) => void;
|
||||
onDelete: (cluster: ClusterConfig) => void;
|
||||
onHealthCheck?: (cluster: ClusterConfig) => void;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
@ -19,12 +23,14 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onHealthCheck,
|
||||
user,
|
||||
}) => {
|
||||
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>
|
||||
<p className="text-slate-500 mt-4">{LoadingText.LOADING_CLUSTERS}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,43 +38,63 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
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>
|
||||
<Server className="w-16 h-16 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-500 text-lg mb-2">{EmptyText.NO_CLUSTERS}</p>
|
||||
<p className="text-slate-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"
|
||||
>
|
||||
{clusters.map((cluster) => {
|
||||
const access = cluster as ClusterConfig & ResourceWithAccess;
|
||||
const canEdit = canUseResourceAction(access, "update", user);
|
||||
const canDelete = canUseResourceAction(access, "delete", user);
|
||||
const canTest = canUseResourceAction(access, "test", user);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:bg-slate-50 hover:border-slate-300 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">
|
||||
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2 mb-1">
|
||||
<Server className="w-5 h-5 text-blue-400" />
|
||||
{cluster.name}
|
||||
<span className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-medium text-slate-500">
|
||||
{getVisibilityLabel(access.visibility)}
|
||||
</span>
|
||||
</h3>
|
||||
{cluster.description && (
|
||||
<p className="text-sm text-gray-400">{cluster.description}</p>
|
||||
<p className="text-sm text-slate-500">{cluster.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{onHealthCheck && canTest && (
|
||||
<button
|
||||
onClick={() => onHealthCheck(cluster)}
|
||||
className="p-2 text-slate-500 hover:text-emerald-700 hover:bg-emerald-50 rounded-lg transition"
|
||||
title="Test Connection"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onEdit(cluster)}
|
||||
className="p-2 text-gray-400 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition"
|
||||
title="Edit"
|
||||
disabled={!canEdit}
|
||||
className="p-2 text-slate-500 hover:text-blue-400 hover:bg-blue-50 rounded-lg transition"
|
||||
title={canEdit ? "Edit" : "Read-only"}
|
||||
>
|
||||
<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"
|
||||
disabled={!canDelete}
|
||||
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-50 rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-500"
|
||||
title={canDelete ? "Delete" : "Read-only"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@ -80,8 +106,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">API Server URL</p>
|
||||
<p className="text-sm text-slate-700 font-mono truncate" title={cluster.host}>
|
||||
{cluster.host}
|
||||
</p>
|
||||
</div>
|
||||
@ -91,8 +117,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">CA Certificate</p>
|
||||
<p className={`text-xs ${cluster.hasCaData ? "text-green-400" : "text-slate-500"}`}>
|
||||
{cluster.hasCaData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -101,8 +127,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Client Cert</p>
|
||||
<p className={`text-xs ${cluster.hasCertData ? "text-yellow-400" : "text-slate-500"}`}>
|
||||
{cluster.hasCertData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -111,8 +137,8 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
<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"}`}>
|
||||
<p className="text-xs text-slate-500 mb-0.5">Client Key</p>
|
||||
<p className={`text-xs ${cluster.hasKeyData ? "text-red-400" : "text-slate-500"}`}>
|
||||
{cluster.hasKeyData ? "✓ Configured" : "✗ Not Configured"}
|
||||
</p>
|
||||
</div>
|
||||
@ -122,14 +148,15 @@ export const ClusterList: React.FC<ClusterListProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{cluster.createdAt && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700/50">
|
||||
<p className="text-xs text-gray-500">
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
Created: {new Date(cluster.createdAt).toLocaleString("en-US")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { useToast, Modal, Button, PageHeader } from "@/shared";
|
||||
import { useAuth } from "@/app/providers";
|
||||
import { isAdminUser } from "@/app/providers/auth-model";
|
||||
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { ClusterForm } from "../components/ClusterForm";
|
||||
import { ClusterList } from "../components/ClusterList";
|
||||
@ -13,11 +15,14 @@ import {
|
||||
createCluster,
|
||||
updateCluster,
|
||||
deleteCluster,
|
||||
getClusterHealth,
|
||||
} from "@/api";
|
||||
import type { ClusterConfig, ClusterResponse } from "@/core/types";
|
||||
|
||||
const ClusterConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = isAdminUser(user);
|
||||
|
||||
const [clusters, setClusters] = useState<ClusterConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -170,12 +175,40 @@ const ClusterConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHealthCheck = async (cluster: ClusterResponse) => {
|
||||
if (!cluster.id) {
|
||||
toastError("Cluster identifier is missing. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
toastInfo(`Testing cluster "${cluster.name}"...`, {
|
||||
title: "Cluster Health",
|
||||
durationMs: 1800,
|
||||
mergeKey: `cluster-health-${cluster.id}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getClusterHealth({ clusterId: cluster.id }) as any;
|
||||
if (result.healthy === false) {
|
||||
toastError(result.message || ClusterErrors.CONNECTION_FAILED);
|
||||
return;
|
||||
}
|
||||
success(result.message || SuccessMessages.OPERATION_COMPLETED);
|
||||
} catch (error) {
|
||||
toastError(formatApiError(error) || ClusterErrors.CONNECTION_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Clusters"
|
||||
description="Manage Kubernetes cluster connections and authentication"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage all Kubernetes cluster connections and authentication"
|
||||
: "Manage your private Kubernetes cluster connections"
|
||||
}
|
||||
icon={Server}
|
||||
iconColor="text-blue-400"
|
||||
actions={
|
||||
@ -207,21 +240,10 @@ const ClusterConfigPage: React.FC = () => {
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onHealthCheck={handleHealthCheck}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
|
||||
@ -104,7 +104,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.NAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -114,13 +114,13 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Registry URL <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -130,16 +130,16 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-slate-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">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.USERNAME} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -149,21 +149,21 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<div className="mb-2 flex items-center gap-2 px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg">
|
||||
<span className="text-slate-500 text-sm">当前密码:</span>
|
||||
<span className="text-slate-900 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>
|
||||
@ -176,9 +176,9 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
💡 输入新密码以覆盖原密码,留空则保持原密码不变
|
||||
</p>
|
||||
</>
|
||||
@ -191,14 +191,14 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{LabelText.DESCRIPTION}
|
||||
</label>
|
||||
<textarea
|
||||
@ -207,7 +207,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -219,9 +219,9 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="w-4 h-4 text-purple-600 bg-white border-slate-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<label htmlFor="insecure" className="ml-2 text-sm text-gray-300">
|
||||
<label htmlFor="insecure" className="ml-2 text-sm text-slate-700">
|
||||
Allow insecure connection (skip SSL certificate verification)
|
||||
</label>
|
||||
</div>
|
||||
@ -230,7 +230,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<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"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{ButtonText.SAVE}
|
||||
@ -240,7 +240,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
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"
|
||||
className="px-4 py-2 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<TestTube className={`w-4 h-4 ${testing ? "animate-pulse" : ""}`} />
|
||||
{ButtonText.TEST_CONNECTION}
|
||||
@ -249,7 +249,7 @@ export const RegistryForm: React.FC<RegistryFormProps> = ({
|
||||
<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"
|
||||
className="px-4 py-2 bg-white hover:bg-slate-100 text-slate-900 rounded-lg transition flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{ButtonText.CANCEL}
|
||||
|
||||
@ -7,12 +7,15 @@ 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";
|
||||
import type { User } from "@/app/providers/AuthContext";
|
||||
import { canUseResourceAction, getVisibilityLabel, type ResourceWithAccess } from "@/app/providers/auth-model";
|
||||
|
||||
interface RegistryListProps {
|
||||
registries: AppRegistry[];
|
||||
loading: boolean;
|
||||
onEdit: (registry: AppRegistry) => void;
|
||||
onDelete: (registry: AppRegistry) => void;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
@ -20,10 +23,11 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
user,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
{LoadingText.LOADING_REGISTRIES}
|
||||
</div>
|
||||
);
|
||||
@ -41,19 +45,27 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
|
||||
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"
|
||||
>
|
||||
{registries.map((registry) => {
|
||||
const access = registry as AppRegistry & ResourceWithAccess;
|
||||
const canEdit = canUseResourceAction(access, "update", user);
|
||||
const canDelete = canUseResourceAction(access, "delete", user);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={registry.id}
|
||||
className="p-4 bg-slate-50 border border-slate-200 rounded-lg hover:border-slate-300 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">
|
||||
<Database className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{registry.name}
|
||||
</h3>
|
||||
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px] font-medium text-slate-500">
|
||||
{getVisibilityLabel(access.visibility)}
|
||||
</span>
|
||||
{registry.insecure && (
|
||||
<span className="px-2 py-0.5 bg-yellow-900/30 text-yellow-400 text-xs rounded">
|
||||
Insecure
|
||||
@ -62,25 +74,25 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="ml-8 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<a
|
||||
href={registry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-purple-400 transition"
|
||||
className="hover:text-blue-600 transition"
|
||||
>
|
||||
{registry.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{registry.description && (
|
||||
<p className="text-sm text-gray-500">{registry.description}</p>
|
||||
<p className="text-sm text-slate-500">{registry.description}</p>
|
||||
)}
|
||||
|
||||
{registry.username && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Username: <span className="text-gray-400">{registry.username}</span>
|
||||
<p className="text-sm text-slate-500">
|
||||
Username: <span className="text-slate-500">{registry.username}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -90,22 +102,25 @@ export const RegistryList: React.FC<RegistryListProps> = ({
|
||||
<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"
|
||||
disabled={!canEdit}
|
||||
className="p-2 text-slate-500 hover:text-blue-400 hover:bg-white rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500"
|
||||
title={canEdit ? "Edit" : "Read-only"}
|
||||
>
|
||||
<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"
|
||||
disabled={!canDelete}
|
||||
className="p-2 text-slate-500 hover:text-red-400 hover:bg-white rounded-lg transition disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500"
|
||||
title={canDelete ? "Delete" : "Read-only"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, RefreshCw, Database } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { useAuth } from "@/app/providers";
|
||||
import { isAdminUser } from "@/app/providers/auth-model";
|
||||
import { Modal, PageHeader, Button } from "@/shared/components";
|
||||
import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
import { RegistryForm } from "../components/RegistryForm";
|
||||
@ -20,6 +22,8 @@ import type { AppRegistry, RegistryResponse } from "@/core/types";
|
||||
|
||||
const RegistryConfigPage: React.FC = () => {
|
||||
const { success, error: toastError, info: toastInfo } = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = isAdminUser(user);
|
||||
|
||||
const [registries, setRegistries] = useState<AppRegistry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -176,9 +180,13 @@ const RegistryConfigPage: React.FC = () => {
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Configuration - Registries"
|
||||
description="Manage OCI Registry connections and authentication"
|
||||
description={
|
||||
isAdmin
|
||||
? "Manage all OCI registry connections and authentication"
|
||||
: "Manage your private OCI registry connections"
|
||||
}
|
||||
icon={Database}
|
||||
iconColor="text-purple-400"
|
||||
iconColor="text-blue-600"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@ -207,20 +215,9 @@ const RegistryConfigPage: React.FC = () => {
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
|
||||
@ -0,0 +1,502 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Gauge, KeyRound, Pencil, RefreshCw, Shield, Trash2, UserPlus, Users, X } from "lucide-react";
|
||||
import { createUser, listClusters, listUsers, updateUser, deleteUser, type ClusterResponse, type UserResponse } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
import { Button, Input, Badge, LoadingState } from "@/shared/components";
|
||||
import { formatApiError } from "@/shared/utils";
|
||||
import { useAuth } from "@/app/providers";
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { success, error: toastError } = useToast();
|
||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||
const [clusters, setClusters] = useState<ClusterResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("user");
|
||||
const [namespace, setNamespace] = useState("");
|
||||
const [defaultClusterId, setDefaultClusterId] = useState("");
|
||||
const [quotaCpu, setQuotaCpu] = useState("4");
|
||||
const [quotaMemory, setQuotaMemory] = useState("16Gi");
|
||||
const [quotaGpu, setQuotaGpu] = useState("0");
|
||||
const [quotaGpuMemory, setQuotaGpuMemory] = useState("0");
|
||||
const [mustChangePassword, setMustChangePassword] = useState(true);
|
||||
const [editingLimits, setEditingLimits] = useState<UserResponse | null>(null);
|
||||
const [editNamespace, setEditNamespace] = useState("");
|
||||
const [editDefaultClusterId, setEditDefaultClusterId] = useState("");
|
||||
const [editQuotaCpu, setEditQuotaCpu] = useState("");
|
||||
const [editQuotaMemory, setEditQuotaMemory] = useState("");
|
||||
const [editQuotaGpu, setEditQuotaGpu] = useState("");
|
||||
const [editQuotaGpuMemory, setEditQuotaGpuMemory] = useState("");
|
||||
const [savingLimits, setSavingLimits] = useState(false);
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => (a.username ?? "").localeCompare(b.username ?? "")),
|
||||
[users]
|
||||
);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUsers(await listUsers());
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadClusters = async () => {
|
||||
try {
|
||||
const data = await listClusters();
|
||||
const available = data.filter((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
|
||||
setClusters(available);
|
||||
setDefaultClusterId((current) => current || available[0]?.id || "");
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load clusters");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
void loadClusters();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password.trim()) {
|
||||
toastError("Username and initial password are required.");
|
||||
return;
|
||||
}
|
||||
const effectiveNamespace = namespace.trim() || namespaceForUsername(username);
|
||||
setCreating(true);
|
||||
try {
|
||||
await createUser({
|
||||
username: username.trim(),
|
||||
password,
|
||||
role,
|
||||
...(role === "user"
|
||||
? {
|
||||
namespace: effectiveNamespace,
|
||||
defaultClusterId: defaultClusterId.trim(),
|
||||
quotaCpu: quotaCpu.trim(),
|
||||
quotaMemory: quotaMemory.trim(),
|
||||
quotaGpu: quotaGpu.trim(),
|
||||
quotaGpuMemory: quotaGpuMemory.trim(),
|
||||
}
|
||||
: {}),
|
||||
mustChangePassword,
|
||||
isActive: true,
|
||||
});
|
||||
success("User created");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setRole("user");
|
||||
setNamespace("");
|
||||
setDefaultClusterId(clusters[0]?.id || "");
|
||||
setQuotaCpu("4");
|
||||
setQuotaMemory("16Gi");
|
||||
setQuotaGpu("0");
|
||||
setQuotaGpuMemory("0");
|
||||
setMustChangePassword(true);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== "user") {
|
||||
return;
|
||||
}
|
||||
setNamespace((current) => {
|
||||
if (current.trim() && !current.startsWith("ocdp-u-")) {
|
||||
return current;
|
||||
}
|
||||
return namespaceForUsername(username);
|
||||
});
|
||||
}, [username, role]);
|
||||
|
||||
const toggleActive = async (target: UserResponse) => {
|
||||
if (!target.id) return;
|
||||
try {
|
||||
await updateUser(target.id, { isActive: !target.isActive });
|
||||
success(target.isActive ? "User disabled" : "User enabled");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update user");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = async (target: UserResponse) => {
|
||||
if (!target.id) return;
|
||||
const nextRole = target.role === "admin" ? "user" : "admin";
|
||||
try {
|
||||
await updateUser(target.id, { role: nextRole });
|
||||
success("User role updated");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update role");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (target: UserResponse) => {
|
||||
if (!target.id || target.id === user?.userId) return;
|
||||
if (!window.confirm(`Delete user ${target.username}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteUser(target.id);
|
||||
success("User deleted");
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to delete user");
|
||||
}
|
||||
};
|
||||
|
||||
const openLimitsEditor = (target: UserResponse) => {
|
||||
setEditingLimits(target);
|
||||
setEditNamespace(target.namespace || namespaceForUsername(target.username || ""));
|
||||
setEditDefaultClusterId(target.defaultClusterId || clusters[0]?.id || "");
|
||||
setEditQuotaCpu(target.quotaCpu || "4");
|
||||
setEditQuotaMemory(target.quotaMemory || "16Gi");
|
||||
setEditQuotaGpu(target.quotaGpu || "0");
|
||||
setEditQuotaGpuMemory(target.quotaGpuMemory || "0");
|
||||
};
|
||||
|
||||
const saveLimits = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editingLimits?.id) return;
|
||||
setSavingLimits(true);
|
||||
try {
|
||||
await updateUser(editingLimits.id, {
|
||||
namespace: editNamespace.trim(),
|
||||
defaultClusterId: editDefaultClusterId.trim(),
|
||||
quotaCpu: editQuotaCpu.trim(),
|
||||
quotaMemory: editQuotaMemory.trim(),
|
||||
quotaGpu: editQuotaGpu.trim(),
|
||||
quotaGpuMemory: editQuotaGpuMemory.trim(),
|
||||
});
|
||||
success("User limits updated");
|
||||
setEditingLimits(null);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to update user limits");
|
||||
} finally {
|
||||
setSavingLimits(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-blue-700">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin only
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-slate-950">User Management</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Create accounts, assign roles, and disable access without public self-registration.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" icon={RefreshCw} onClick={loadUsers} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<form onSubmit={handleCreate} className="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-base font-semibold text-slate-900">Create User</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Username
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Initial password
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Role
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
{role === "user" && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<KeyRound className="h-4 w-4 text-blue-600" />
|
||||
Tenant namespace
|
||||
</div>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Namespace
|
||||
<Input
|
||||
value={namespace}
|
||||
onChange={(e) => setNamespace(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
placeholder={namespaceForUsername(username)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-sm font-medium text-slate-700">
|
||||
Default cluster
|
||||
<select
|
||||
value={defaultClusterId}
|
||||
onChange={(event) => setDefaultClusterId(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select a cluster</option>
|
||||
{clusters.map((cluster) => (
|
||||
<option key={cluster.id} value={cluster.id}>
|
||||
{cluster.name || cluster.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{role === "user" && (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Gauge className="h-4 w-4 text-blue-600" />
|
||||
Resource limits
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={quotaCpu} onChange={(e) => setQuotaCpu(e.target.value)} className="mt-1" placeholder="4" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={quotaMemory} onChange={(e) => setQuotaMemory(e.target.value)} className="mt-1" placeholder="16Gi" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
<Input value={quotaGpu} onChange={(e) => setQuotaGpu(e.target.value)} className="mt-1" placeholder="0" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU Mem
|
||||
<Input value={quotaGpuMemory} onChange={(e) => setQuotaGpuMemory(e.target.value)} className="mt-1" placeholder="0" />
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
CPU and memory use Kubernetes quantities. GPU memory is an integer MB value, for example 10000.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mustChangePassword}
|
||||
onChange={(event) => setMustChangePassword(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
Require password change after first login
|
||||
</label>
|
||||
<Button type="submit" variant="primary" icon={UserPlus} loading={creating} className="w-full">
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="min-w-0 rounded-lg border border-slate-200 bg-white shadow-soft">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-slate-500" />
|
||||
<h2 className="text-base font-semibold text-slate-900">Accounts</h2>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">{users.length} users</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<LoadingState message="Loading users..." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3">User</th>
|
||||
<th className="px-5 py-3">Role</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Namespace</th>
|
||||
<th className="px-5 py-3">Quota</th>
|
||||
<th className="sticky right-0 z-10 bg-slate-50 px-5 py-3 text-right shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedUsers.map((target) => (
|
||||
<tr key={target.id} className="group hover:bg-slate-50">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-slate-900">{target.username}</div>
|
||||
<div className="text-xs text-slate-500">{target.email}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={target.role === "admin" ? "info" : "secondary"} size="sm">
|
||||
{target.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={target.isActive ? "success" : "warning"} size="sm">
|
||||
{target.isActive ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-mono text-xs text-slate-700">{target.namespace || "-"}</div>
|
||||
<div className="text-xs text-slate-500">{target.workspaceName || target.workspaceId}</div>
|
||||
{target.defaultClusterId && (
|
||||
<div className="mt-1 text-xs text-blue-700">{clusterName(clusters, target.defaultClusterId)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-slate-600">
|
||||
{target.role === "admin" ? (
|
||||
<span className="text-slate-400">default workspace</span>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<span>CPU {target.quotaCpu || "-"}</span>
|
||||
<span>Mem {target.quotaMemory || "-"}</span>
|
||||
<span>GPU {target.quotaGpu || "0"} / Mem {target.quotaGpuMemory || "0"}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="sticky right-0 bg-white px-5 py-3 shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)] group-hover:bg-slate-50">
|
||||
<div className="grid w-[260px] grid-cols-2 gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => toggleRole(target)}>
|
||||
Make {target.role === "admin" ? "User" : "Admin"}
|
||||
</Button>
|
||||
{target.role !== "admin" && (
|
||||
<Button type="button" variant="secondary" size="sm" icon={Pencil} onClick={() => openLimitsEditor(target)}>
|
||||
Limits
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => toggleActive(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
{target.isActive ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{editingLimits && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-sm">
|
||||
<form onSubmit={saveLimits} className="w-full max-w-xl rounded-xl border border-slate-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Tenant limits
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-slate-950">{editingLimits.username}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Changes are applied to workspace metadata and the next tenant binding/deploy refreshes Kubernetes ResourceQuota.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingLimits(null)} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Namespace
|
||||
<Input value={editNamespace} onChange={(e) => setEditNamespace(e.target.value)} className="mt-1 font-mono" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Default cluster
|
||||
<select
|
||||
value={editDefaultClusterId}
|
||||
onChange={(event) => setEditDefaultClusterId(event.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select a cluster</option>
|
||||
{clusters.map((cluster) => (
|
||||
<option key={cluster.id} value={cluster.id}>
|
||||
{cluster.name || cluster.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={editQuotaCpu} onChange={(e) => setEditQuotaCpu(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={editQuotaMemory} onChange={(e) => setEditQuotaMemory(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
<Input value={editQuotaGpu} onChange={(e) => setEditQuotaGpu(e.target.value)} className="mt-1" required />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU memory
|
||||
<Input value={editQuotaGpuMemory} onChange={(e) => setEditQuotaGpuMemory(e.target.value)} className="mt-1" placeholder="10000" required />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-slate-200 px-6 py-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setEditingLimits(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={savingLimits}>
|
||||
Save Limits
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const namespaceForUsername = (username: string): string => {
|
||||
const label = username
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return `ocdp-u-${label || "user"}`.slice(0, 63).replace(/-+$/g, "");
|
||||
};
|
||||
|
||||
const clusterName = (clusters: ClusterResponse[], clusterId: string): string => {
|
||||
const cluster = clusters.find((candidate) => candidate.id === clusterId);
|
||||
return cluster?.name || clusterId;
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
@ -1,244 +1,159 @@
|
||||
import React from "react";
|
||||
import { Boxes, Server, Database, Activity, Package, Rocket, Settings } from "lucide-react";
|
||||
import { Activity, ArrowRight, Boxes, Database, Gauge, Package, Rocket, Server, Settings, Users } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/app/providers";
|
||||
|
||||
const primaryActions = [
|
||||
{
|
||||
title: "Launch Instance",
|
||||
description: "Browse Helm charts and deploy a new inference service.",
|
||||
path: "/artifact/registries",
|
||||
icon: Rocket,
|
||||
accent: "bg-blue-600 text-white",
|
||||
},
|
||||
{
|
||||
title: "Instances",
|
||||
description: "Check release status, entries, upgrades, and deletion.",
|
||||
path: "/artifact/instances",
|
||||
icon: Package,
|
||||
accent: "bg-emerald-600 text-white",
|
||||
},
|
||||
{
|
||||
title: "Cluster Monitoring",
|
||||
description: "Inspect cluster health and node resource pressure.",
|
||||
path: "/monitoring/clusters",
|
||||
icon: Activity,
|
||||
accent: "bg-slate-900 text-white",
|
||||
},
|
||||
];
|
||||
|
||||
const setupActions = [
|
||||
{
|
||||
title: "Clusters",
|
||||
description: "Kubeconfig and namespace policy",
|
||||
path: "/configuration/clusters",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
title: "Registries",
|
||||
description: "Harbor robot account and chart access",
|
||||
path: "/configuration/registries",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
description: "Admin-only account management",
|
||||
path: "/configuration/users",
|
||||
icon: Users,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
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 className="min-h-full bg-slate-50 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<section className="grid gap-5 lg:grid-cols-[1.4fr_0.8fr]">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-soft sm:p-8">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-600 text-white">
|
||||
<Boxes className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-blue-700">One Click Deployment Platform</p>
|
||||
<h1 className="text-3xl font-semibold text-slate-950 sm:text-4xl">Operations Workbench</h1>
|
||||
</div>
|
||||
</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 className="grid gap-4 md:grid-cols-3">
|
||||
{primaryActions.map((action) => (
|
||||
<button
|
||||
key={action.path}
|
||||
type="button"
|
||||
onClick={() => navigate(action.path)}
|
||||
className="group flex min-h-[190px] flex-col justify-between rounded-lg border border-slate-200 bg-slate-50 p-5 text-left transition hover:-translate-y-0.5 hover:border-blue-300 hover:bg-white hover:shadow-md"
|
||||
>
|
||||
<div>
|
||||
<div className={`mb-4 flex h-11 w-11 items-center justify-center rounded-lg ${action.accent}`}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-950">{action.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">{action.description}</p>
|
||||
</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 className="mt-5 flex items-center gap-2 text-sm font-medium text-blue-700">
|
||||
Open <ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
|
||||
</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>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<aside className="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-950">Runtime Focus</h2>
|
||||
<p className="text-sm text-slate-500">High-frequency checks</p>
|
||||
</div>
|
||||
<Gauge className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/artifact/instances")}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-slate-200 px-4 py-3 text-left hover:border-emerald-300 hover:bg-emerald-50"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-900">Release status</span>
|
||||
<span className="text-xs text-slate-500">Installed, failed, deleting</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/monitoring/clusters")}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-slate-200 px-4 py-3 text-left hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-900">Cluster health</span>
|
||||
<span className="text-xs text-slate-500">Nodes, pods, CPU, memory</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-slate-500" />
|
||||
<h2 className="text-base font-semibold text-slate-950">Setup</h2>
|
||||
<span className="text-sm text-slate-500">Less frequent administrative tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{setupActions
|
||||
.filter((action) => !action.adminOnly || isAdmin)
|
||||
.map((action) => (
|
||||
<button
|
||||
key={action.path}
|
||||
type="button"
|
||||
onClick={() => navigate(action.path)}
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-left transition hover:border-slate-300 hover:bg-white"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-slate-600 shadow-sm">
|
||||
<action.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-900">{action.title}</span>
|
||||
<span className="block truncate text-xs text-slate-500">{action.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -55,7 +55,7 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
case "unhealthy":
|
||||
return <XCircle className="w-5 h-5 text-red-400" />;
|
||||
default:
|
||||
return <HelpCircle className="w-5 h-5 text-gray-400" />;
|
||||
return <HelpCircle className="w-5 h-5 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -64,66 +64,66 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<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">
|
||||
<div className="p-3 bg-white 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>
|
||||
<h3 className="text-lg font-semibold text-slate-900 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>
|
||||
<p className="text-xs text-slate-500">Uptime</p>
|
||||
<p className="text-sm text-slate-700 font-mono mt-1">{uptime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Nodes</p>
|
||||
<p className="text-xs text-slate-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>
|
||||
<p className="text-sm text-slate-700 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>
|
||||
<p className="text-xs text-slate-500">Pods</p>
|
||||
<p className="text-sm text-slate-700 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">
|
||||
<p className="text-xs text-slate-500">GPU</p>
|
||||
<p className="text-sm text-slate-700 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 className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3 p-3 bg-slate-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>
|
||||
<p className="text-xs text-slate-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">
|
||||
<p className="text-sm text-slate-700 font-mono">{usedCpu} / {totalCpu}</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 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>
|
||||
<p className="text-xs text-slate-500 mt-1">{cpuUsage.toFixed(1)}%</p>
|
||||
{cluster.maxNodeCpu && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-gray-700/50">
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<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>
|
||||
<p className="text-xs text-slate-500">Max per node</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeCpu}</p>
|
||||
<p className="text-xs text-slate-500 font-mono">{cluster.maxNodeCpu}</p>
|
||||
{cluster.maxNodeCpuUsage && cluster.maxNodeCpuUsage > 0 && (
|
||||
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeCpuUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500">Peak: {cluster.maxNodeCpuUsage.toFixed(1)}%</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -132,25 +132,25 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<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>
|
||||
<p className="text-xs text-slate-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">
|
||||
<p className="text-sm text-slate-700 font-mono">{usedMemory} / {totalMemory}</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 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>
|
||||
<p className="text-xs text-slate-500 mt-1">{memoryUsage.toFixed(1)}%</p>
|
||||
{cluster.maxNodeMemory && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-gray-700/50">
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<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>
|
||||
<p className="text-xs text-slate-500">Max per node</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeMemory}</p>
|
||||
<p className="text-xs text-slate-500 font-mono">{cluster.maxNodeMemory}</p>
|
||||
{cluster.maxNodeMemUsage && cluster.maxNodeMemUsage > 0 && (
|
||||
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeMemUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500">Peak: {cluster.maxNodeMemUsage.toFixed(1)}%</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -160,25 +160,25 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<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>
|
||||
<p className="text-xs text-slate-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">
|
||||
<p className="text-sm text-slate-700 font-mono">{usedGpu} / {totalGpu}</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 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>
|
||||
<p className="text-xs text-slate-500 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="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<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>
|
||||
<p className="text-xs text-slate-500">Max per node</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-mono">{cluster.maxNodeGpu} GPUs</p>
|
||||
<p className="text-xs text-slate-500 font-mono">{cluster.maxNodeGpu} GPUs</p>
|
||||
{cluster.maxNodeGpuUsage && cluster.maxNodeGpuUsage > 0 && (
|
||||
<p className="text-xs text-gray-500">Peak: {cluster.maxNodeGpuUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500">Peak: {cluster.maxNodeGpuUsage.toFixed(1)}%</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -186,7 +186,7 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Last checked: {lastCheckedText}</span>
|
||||
</div>
|
||||
@ -218,8 +218,8 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
|
||||
{/* 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">
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<ServerIcon className="w-4 h-4 text-blue-400" />
|
||||
Cluster Nodes ({cluster.nodes.length})
|
||||
</h4>
|
||||
|
||||
@ -39,16 +39,16 @@ export const NodeMetricCard: React.FC<NodeMetricCardProps> = ({ node }) => {
|
||||
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">
|
||||
<div className="p-4 bg-white rounded-lg border border-slate-200 hover:border-slate-300/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">
|
||||
<div className="p-2 bg-slate-100/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>
|
||||
<h4 className="text-sm font-semibold text-slate-900">{node.nodeName}</h4>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -58,8 +58,8 @@ export const NodeMetricCard: React.FC<NodeMetricCardProps> = ({ node }) => {
|
||||
</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>
|
||||
<p className="text-xs text-slate-500">Age</p>
|
||||
<p className="text-xs text-slate-700 font-mono">{node.age}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -69,76 +69,76 @@ export const NodeMetricCard: React.FC<NodeMetricCardProps> = ({ node }) => {
|
||||
<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>
|
||||
<p className="text-xs text-slate-500">CPU</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300 font-mono mb-1">
|
||||
<p className="text-xs text-slate-700 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-1 bg-slate-100 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>
|
||||
<p className="text-xs text-slate-500 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>
|
||||
<p className="text-xs text-slate-500">Memory</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300 font-mono mb-1">
|
||||
<p className="text-xs text-slate-700 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-1 bg-slate-100 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>
|
||||
<p className="text-xs text-slate-500 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>
|
||||
<p className="text-xs text-slate-500">GPU</p>
|
||||
</div>
|
||||
{gpuCapacity > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-300 font-mono mb-1">
|
||||
<p className="text-xs text-slate-700 font-mono mb-1">
|
||||
{node.gpuUsage ?? "N/A"} / {gpuCapacity}
|
||||
</p>
|
||||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-1 bg-slate-100 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">
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{gpuPercent.toFixed(1)}%
|
||||
{node.gpuType && <span className="ml-1 text-gray-500">({node.gpuType})</span>}
|
||||
{node.gpuType && <span className="ml-1 text-slate-500">({node.gpuType})</span>}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mt-1">No GPU</p>
|
||||
<p className="text-xs text-slate-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 className="mt-3 pt-3 border-t border-slate-200 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>
|
||||
<p className="text-xs text-slate-500">Pods</p>
|
||||
<p className="text-xs text-slate-700 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>
|
||||
<p className="text-xs text-slate-500">Kubelet</p>
|
||||
<p className="text-xs text-slate-700 font-mono">{node.kubeletVersion}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -155,7 +155,7 @@ const MonitoringClustersPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh Info */}
|
||||
<div className="text-sm text-gray-400">
|
||||
<div className="text-sm text-slate-500">
|
||||
Auto-refresh every 30 seconds {refreshing && "• Refreshing..."}
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,8 +2,29 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f6f8fb;
|
||||
color: #142033;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.surface-panel {
|
||||
@apply bg-white border border-slate-200 shadow-soft;
|
||||
}
|
||||
|
||||
.control-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fadeIn { animation: fadeIn 0.25s ease-out; }
|
||||
.animate-fadeIn { animation: fadeIn 0.25s ease-out; }
|
||||
|
||||
@ -61,9 +61,9 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
<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>}
|
||||
<p className="text-2xl font-bold text-slate-900">{value}</p>
|
||||
<p className="text-sm text-slate-500">{title}</p>
|
||||
{subtitle && <p className="text-xs text-slate-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,11 +24,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
action,
|
||||
}) => {
|
||||
return (
|
||||
<div className="text-center py-16 bg-gray-800/30 border border-gray-700 border-dashed rounded-lg">
|
||||
<div className="text-center py-16 bg-white border border-slate-200 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>
|
||||
<h3 className="text-lg font-semibold text-slate-500 mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mb-6">{description}</p>
|
||||
<p className="text-sm text-slate-500 mb-6">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
|
||||
@ -23,10 +23,10 @@ export const EmptyStateSimple: React.FC<EmptyStateSimpleProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-gray-800/30 border border-gray-700 rounded-lg p-8 text-center ${className}`}>
|
||||
<div className={`bg-white border border-slate-200 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>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">{title}</h3>
|
||||
<p className="text-slate-500">{description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -23,14 +23,14 @@ export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
}) => {
|
||||
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 className={`${sizeStyles[size]} border-blue-600 border-t-transparent rounded-full animate-spin`} />
|
||||
{message && <p className="text-sm text-slate-500">{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">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-50">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@ -42,4 +42,3 @@ export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputE
|
||||
|
||||
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";
|
||||
const baseStyles = "w-4 h-4 text-blue-600 bg-white border-slate-300 rounded focus:ring-2 focus:ring-blue-500 focus:ring-offset-white cursor-pointer";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -22,7 +22,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label className="text-sm text-gray-300 cursor-pointer select-none" onClick={(e) => {
|
||||
<label className="text-sm text-slate-700 cursor-pointer select-none" onClick={(e) => {
|
||||
if (props.id) {
|
||||
const checkbox = document.getElementById(props.id) as HTMLInputElement;
|
||||
if (checkbox && e.target === e.currentTarget) {
|
||||
@ -43,4 +43,3 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
);
|
||||
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-2xl flex flex-col"
|
||||
className="fixed bg-white border border-slate-200 rounded-md shadow-2xl flex flex-col"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
@ -192,7 +192,7 @@ export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<li className="px-3 py-2 text-gray-500 text-sm text-center">
|
||||
<li className="px-3 py-2 text-slate-500 text-sm text-center">
|
||||
No options available
|
||||
</li>
|
||||
) : (
|
||||
@ -204,14 +204,14 @@ export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
<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 ${
|
||||
className={`px-4 py-3 text-sm transition border-b border-slate-100 last:border-0 ${
|
||||
option.disabled
|
||||
? 'text-gray-600 cursor-not-allowed bg-gray-900/50'
|
||||
? 'text-slate-400 cursor-not-allowed bg-slate-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'
|
||||
? 'bg-blue-50 text-blue-700 cursor-pointer'
|
||||
: 'text-slate-700 hover:bg-slate-50 cursor-pointer'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
@ -237,31 +237,31 @@ export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
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 ${
|
||||
className={`w-full px-3 py-2 bg-white border border-slate-300 text-slate-900 rounded-md 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'
|
||||
: 'cursor-pointer hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
|
||||
}`}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
<span className={value ? 'text-slate-900' : 'text-slate-400'}>
|
||||
{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"
|
||||
className="p-0.5 hover:bg-slate-100 rounded transition"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
<X className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${
|
||||
isOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
@ -285,4 +285,3 @@ export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -24,14 +24,14 @@ export const FormField: React.FC<FormFieldProps> = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 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>
|
||||
<p className="mt-1 text-xs text-slate-500">{help}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-400">{error}</p>
|
||||
|
||||
@ -22,8 +22,8 @@ const sizeStyles: Record<InputSize, { input: string; icon: string }> = {
|
||||
|
||||
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 baseStyles = "w-full bg-white border text-slate-900 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-slate-400";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-slate-300";
|
||||
const iconPadding = Icon
|
||||
? iconPosition === "left"
|
||||
? "pl-10"
|
||||
@ -41,13 +41,13 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<div className="relative">
|
||||
{Icon && iconPosition === "left" && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-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">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
<Icon className={sizeStyles[size].icon} />
|
||||
</div>
|
||||
)}
|
||||
@ -60,4 +60,3 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
|
||||
@ -110,10 +110,10 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
if (enumOptions.length > SEARCHABLE_THRESHOLD) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
({enumOptions.length} options - searchable)
|
||||
</span>
|
||||
</label>
|
||||
@ -125,7 +125,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
required={required}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{description}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -134,7 +134,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
// Use DropdownSelect for small option lists
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
@ -152,7 +152,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
required={required}
|
||||
/>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{description}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -167,7 +167,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
@ -177,7 +177,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 text-slate-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
required={required}
|
||||
/>
|
||||
) : (
|
||||
@ -186,15 +186,15 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 text-slate-900 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>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
{defaultValue !== undefined && (
|
||||
<p className="text-xs text-gray-600 mt-1">Default: {String(defaultValue)}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Default: {String(defaultValue)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -203,7 +203,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
case 'number':
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
@ -218,14 +218,14 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 text-slate-900 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>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
{(schema.minimum !== undefined || schema.maximum !== undefined) && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Range: {schema.minimum !== undefined ? schema.minimum : '∞'} - {schema.maximum !== undefined ? schema.maximum : '∞'}
|
||||
</p>
|
||||
)}
|
||||
@ -234,20 +234,20 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 bg-gray-800/50 rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-slate-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"
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-slate-100 border-slate-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{description}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -257,30 +257,30 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
const currentValue = isRecord(value) ? value : {};
|
||||
const objectSchema = isRecord(schema) ? schema : {};
|
||||
return (
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="border border-slate-200 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"
|
||||
className="w-full flex items-center justify-between p-3 bg-slate-50 hover:bg-white transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
<ChevronDown className="w-4 h-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
<ChevronRight className="w-4 h-4 text-slate-500" />
|
||||
)}
|
||||
<span className="font-medium text-gray-300">
|
||||
<span className="font-medium text-slate-700">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Object</span>
|
||||
<span className="text-xs text-slate-500">Object</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4 bg-gray-900/30">
|
||||
<div className="p-4 space-y-4 bg-slate-50">
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 -mt-2 mb-2">{description}</p>
|
||||
<p className="text-xs text-slate-500 -mt-2 mb-2">{description}</p>
|
||||
)}
|
||||
{schema.properties ? (
|
||||
<SchemaFormGenerator
|
||||
@ -290,7 +290,7 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
No properties defined for this object
|
||||
</p>
|
||||
)}
|
||||
@ -306,10 +306,10 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
: toStringValue(value);
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
<span className="ml-2 text-xs text-gray-500">(Array)</span>
|
||||
<span className="ml-2 text-xs text-slate-500">(Array)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={arrayText}
|
||||
@ -319,13 +319,13 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
}}
|
||||
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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 text-slate-900 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-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">Enter one item per line</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Enter one item per line</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -334,21 +334,21 @@ const FormField: React.FC<FormFieldProps> = ({
|
||||
// Fallback for unknown types
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
<span className="ml-2 text-xs text-gray-500">({type})</span>
|
||||
<span className="ml-2 text-xs text-slate-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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 text-slate-900 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>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -174,7 +174,7 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-2xl flex flex-col"
|
||||
className="fixed bg-white border border-slate-200 rounded-md shadow-2xl flex flex-col"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
@ -185,9 +185,9 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
role="dialog"
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-gray-700 bg-gray-800 flex-shrink-0">
|
||||
<div className="p-2 border-b border-slate-200 bg-white 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" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@ -195,7 +195,7 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
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"
|
||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-300 text-slate-900 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@ -213,7 +213,7 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
role="listbox"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<li className="px-3 py-2 text-gray-500 text-sm text-center">
|
||||
<li className="px-3 py-2 text-slate-500 text-sm text-center">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
@ -221,12 +221,12 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
<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 ${
|
||||
className={`px-4 py-3 text-sm cursor-pointer transition border-b border-slate-100 last:border-0 ${
|
||||
index === highlightedIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: value === option
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700'
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === option}
|
||||
@ -241,7 +241,7 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
|
||||
{/* 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">
|
||||
<div className="px-3 py-2 bg-slate-50 border-t border-slate-200 text-xs text-slate-500 flex items-center justify-between flex-shrink-0">
|
||||
<span>
|
||||
{searchTerm
|
||||
? `${filteredOptions.length} of ${options.length} options`
|
||||
@ -249,7 +249,7 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
}
|
||||
</span>
|
||||
{!searchTerm && options.length > 20 && (
|
||||
<span className="text-blue-400">💡 Type to search</span>
|
||||
<span className="text-blue-600">Type to search</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -263,23 +263,23 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
{/* 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"
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 text-slate-900 rounded-md cursor-pointer hover:border-slate-400 transition flex items-center justify-between"
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
<span className={value ? 'text-slate-900' : 'text-slate-400'}>
|
||||
{displayText || placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{value && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-0.5 hover:bg-gray-700 rounded transition"
|
||||
className="p-0.5 hover:bg-slate-100 rounded transition"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
<X className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${
|
||||
isOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
@ -303,4 +303,3 @@ export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -18,8 +18,8 @@ const sizeStyles: Record<TextareaSize, string> = {
|
||||
|
||||
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 baseStyles = "w-full bg-white border text-slate-900 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-slate-400 resize-y";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-slate-300";
|
||||
|
||||
const combinedClassName = `
|
||||
${baseStyles}
|
||||
@ -40,4 +40,3 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ export default function AppShell({
|
||||
title = "应用管理",
|
||||
icon = <LayoutDashboard className="w-5 h-5 text-brand-accent" />,
|
||||
userName = "User",
|
||||
userRole,
|
||||
navItems = [],
|
||||
onSignOut,
|
||||
children,
|
||||
@ -15,13 +16,14 @@ export default function AppShell({
|
||||
title?: string;
|
||||
icon?: React.ReactNode;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
navItems?: NavItem[];
|
||||
onSignOut?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarLayout items={navItems}>
|
||||
<TopNavLayout title={title} icon={icon} userName={userName} onSignOut={onSignOut}>
|
||||
<TopNavLayout title={title} icon={icon} userName={userName} userRole={userRole} onSignOut={onSignOut}>
|
||||
{children}
|
||||
</TopNavLayout>
|
||||
</SidebarLayout>
|
||||
|
||||
@ -32,7 +32,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
icon: Icon,
|
||||
iconColor = "text-purple-400",
|
||||
iconColor = "text-blue-600",
|
||||
children,
|
||||
footer,
|
||||
size = "md",
|
||||
@ -73,15 +73,15 @@ const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={`relative w-full ${sizeCls[size]} my-8 bg-gray-900 text-white border border-gray-700 rounded-xl shadow-2xl`}
|
||||
className={`relative w-full ${sizeCls[size]} my-8 bg-white text-slate-900 border border-slate-200 rounded-lg 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="px-5 sm:px-6 py-4 flex items-center justify-between border-b border-slate-200 sticky top-0 bg-white rounded-t-lg 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'}`}>
|
||||
<div className={`p-2 rounded-md ${iconColor.includes('red') ? 'bg-red-50' : iconColor.includes('green') ? 'bg-emerald-50' : 'bg-blue-50'}`}>
|
||||
<Icon className={`w-5 h-5 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
@ -91,10 +91,10 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-slate-100 rounded-md transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -105,7 +105,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
{/* 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">
|
||||
<div className="px-5 sm:px-6 py-4 border-t border-slate-200 bg-slate-50 sticky bottom-0 rounded-b-lg flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
@ -114,4 +114,4 @@ const Modal: React.FC<ModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
export default Modal;
|
||||
|
||||
@ -18,7 +18,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
iconColor = "text-purple-400",
|
||||
iconColor = "text-blue-600",
|
||||
actions,
|
||||
children,
|
||||
}) => {
|
||||
@ -28,9 +28,9 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{description}</p>
|
||||
<p className="text-sm text-slate-500 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -42,4 +42,3 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ export default function SidebarLayout({ items, children }: SidebarLayoutProps) {
|
||||
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"
|
||||
className="pointer-events-none absolute inset-0 bg-app-gradient"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SidebarNav
|
||||
|
||||
@ -17,7 +17,7 @@ interface SidebarNavProps {
|
||||
}
|
||||
|
||||
export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onClose }: SidebarNavProps) {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set(["configuration", "monitoring", "artifact", "cluster"]));
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set(["setup"]));
|
||||
|
||||
const toggleExpand = (key: string) => {
|
||||
setExpandedKeys((prev) => {
|
||||
@ -55,8 +55,8 @@ export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onC
|
||||
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"
|
||||
? "bg-blue-50 text-blue-700 border border-blue-200 shadow-sm"
|
||||
: "text-slate-600 hover:text-slate-950 hover:bg-slate-100"
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + level * 16}px` }}
|
||||
>
|
||||
@ -93,22 +93,22 @@ export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onC
|
||||
<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
|
||||
flex flex-col w-64 sm:w-72 md:w-60 xl:w-64 bg-white/95 backdrop-blur-xl border-r border-slate-200 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 justify-between px-4 py-4 border-b border-slate-200">
|
||||
<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>
|
||||
<LayoutDashboard className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm font-semibold text-slate-700 tracking-wide">Operations</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"
|
||||
className="md:hidden p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-900 transition"
|
||||
aria-label="关闭菜单"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
@ -122,8 +122,8 @@ export default function SidebarNav({ items = [] as NavItem[], isOpen = true, onC
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 text-xs text-muted border-t border-dark-border/70">
|
||||
© {new Date().getFullYear()} OCDP
|
||||
<div className="p-3 text-xs text-muted border-t border-slate-200">
|
||||
OCDP · {new Date().getFullYear()}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@ -28,7 +28,7 @@ export const Tabs: React.FC<TabsProps> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div className={`border-b border-gray-700 ${className}`}>
|
||||
<div className={`border-b border-slate-200 ${className}`}>
|
||||
{/* 添加水平滚动支持(移动端优化) */}
|
||||
<div className="flex gap-2 sm:gap-4 overflow-x-auto scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
||||
{tabs.map((tab) => {
|
||||
@ -46,7 +46,7 @@ export const Tabs: React.FC<TabsProps> = ({
|
||||
${
|
||||
isActive
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-600"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
||||
}
|
||||
`}
|
||||
>
|
||||
@ -58,7 +58,7 @@ export const Tabs: React.FC<TabsProps> = ({
|
||||
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"
|
||||
"bg-slate-100 text-slate-700 border border-slate-300"
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@ -5,6 +5,7 @@ interface TopNavProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
onSignOut?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
@ -13,39 +14,45 @@ export default function TopNav({
|
||||
title,
|
||||
icon,
|
||||
userName,
|
||||
userRole,
|
||||
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">
|
||||
<header className="sticky top-0 z-40 h-16 flex items-center justify-between px-4 sm:px-6 bg-white/90 border-b border-slate-200 backdrop-blur-xl shadow-sm">
|
||||
<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"
|
||||
className="md:hidden p-2 rounded-lg text-slate-500 hover:text-slate-950 hover:bg-slate-100 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">
|
||||
<h1 className="text-base sm:text-lg font-semibold text-slate-900 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 className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-50 border border-slate-200">
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-slate-600">{userName ?? "User"}</span>
|
||||
{userRole && (
|
||||
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-slate-500">
|
||||
{userRole}
|
||||
</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"
|
||||
className="p-2 rounded-md border border-slate-200 text-slate-500 hover:text-slate-950 hover:bg-slate-100 transition"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-brand-accent" />
|
||||
<LogOut className="w-4 h-4 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -5,6 +5,7 @@ interface TopNavLayoutProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
onSignOut?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
children: React.ReactNode;
|
||||
@ -14,6 +15,7 @@ export default function TopNavLayout({
|
||||
title,
|
||||
icon,
|
||||
userName,
|
||||
userRole,
|
||||
onSignOut,
|
||||
onToggleSidebar,
|
||||
children,
|
||||
@ -24,6 +26,7 @@ export default function TopNavLayout({
|
||||
title={title}
|
||||
icon={icon}
|
||||
userName={userName}
|
||||
userRole={userRole}
|
||||
onSignOut={onSignOut}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
|
||||
@ -17,15 +17,15 @@ export interface BadgeProps {
|
||||
}
|
||||
|
||||
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",
|
||||
default: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
success: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
warning: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
danger: "bg-red-50 text-red-700 border-red-200",
|
||||
info: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
purple: "bg-violet-50 text-violet-700 border-violet-200",
|
||||
gray: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
blue: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
secondary: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<BadgeSize, { badge: string; icon: string }> = {
|
||||
|
||||
@ -20,12 +20,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
||||
}
|
||||
|
||||
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",
|
||||
primary: "bg-blue-600 hover:bg-blue-700 text-white border-blue-600 shadow-sm",
|
||||
secondary: "bg-white hover:bg-slate-50 text-slate-700 border-slate-300 shadow-sm",
|
||||
success: "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white border-red-600 shadow-sm",
|
||||
warning: "bg-amber-500 hover:bg-amber-600 text-white border-amber-500 shadow-sm",
|
||||
ghost: "bg-transparent hover:bg-slate-100 text-slate-600 border-transparent",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, { button: string; icon: string }> = {
|
||||
@ -49,7 +49,7 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
}) => {
|
||||
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 baseStyles = "inline-flex items-center justify-center gap-2 font-medium rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white";
|
||||
const disabledStyles = "disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
|
||||
@ -87,4 +87,3 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@ export interface CardProps {
|
||||
}
|
||||
|
||||
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 baseStyles = "bg-white border border-slate-200 rounded-lg overflow-hidden shadow-sm";
|
||||
const hoverStyles = hover ? "hover:border-blue-300 hover:shadow-md transition-colors cursor-pointer" : "";
|
||||
const clickStyles = onClick ? "cursor-pointer" : "";
|
||||
|
||||
return (
|
||||
@ -27,7 +27,7 @@ export const Card: React.FC<CardProps> = ({ children, className = "", hover = fa
|
||||
};
|
||||
|
||||
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()}>
|
||||
<div className={`px-6 py-4 border-b border-slate-200 ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@ -39,8 +39,7 @@ export const CardBody: React.FC<{ children: React.ReactNode; className?: string
|
||||
);
|
||||
|
||||
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()}>
|
||||
<div className={`px-6 py-4 border-t border-slate-200 bg-slate-50 ${className}`.trim()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -5,21 +5,21 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
dark: {
|
||||
DEFAULT: "#0b1119", // 主背景
|
||||
lighter: "#141c26", // 模块背景
|
||||
elevated: "#1a2533", // 提升层
|
||||
border: "#1f2935", // 分割线
|
||||
DEFAULT: "#f6f8fb", // 主背景
|
||||
lighter: "#ffffff", // 模块背景
|
||||
elevated: "#eef4f8", // 提升层
|
||||
border: "#d8e2ee", // 分割线
|
||||
},
|
||||
brand: {
|
||||
blue: "#56718f", // 主色(柔和蓝)
|
||||
light: "#6f8aa8", // hover
|
||||
accent: "#c6a15b", // 点缀金色
|
||||
accentLight: "#ddc48b",
|
||||
blue: "#2563eb",
|
||||
light: "#3b82f6",
|
||||
accent: "#0f766e",
|
||||
accentLight: "#14b8a6",
|
||||
},
|
||||
accent: {
|
||||
teal: "#4d8275",
|
||||
tealLight: "#6ea094",
|
||||
slate: "#2f3947",
|
||||
teal: "#0f766e",
|
||||
tealLight: "#14b8a6",
|
||||
slate: "#475569",
|
||||
},
|
||||
blue: {
|
||||
300: "#6f8aa8",
|
||||
@ -42,23 +42,23 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
textColor: {
|
||||
primary: "#e5e9f2", // 主字体
|
||||
secondary: "#aab4c3", // 次级文字
|
||||
muted: "#808ba0",
|
||||
primary: "#142033",
|
||||
secondary: "#4b5b70",
|
||||
muted: "#718096",
|
||||
},
|
||||
backgroundImage: {
|
||||
"app-gradient":
|
||||
"radial-gradient(circle at top, rgba(198, 161, 91, 0.12), transparent 58%), radial-gradient(circle at bottom, rgba(77, 130, 117, 0.18), transparent 62%)",
|
||||
"linear-gradient(180deg, #f9fbfd 0%, #eef4f8 100%)",
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "0.75rem",
|
||||
"2xl": "1rem",
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 10px 30px rgba(0, 0, 0, 0.35)",
|
||||
glow: "0 0 25px rgba(198, 161, 91, 0.18)",
|
||||
soft: "0 12px 32px rgba(15, 23, 42, 0.08)",
|
||||
glow: "0 0 0 1px rgba(37, 99, 235, 0.12), 0 16px 42px rgba(37, 99, 235, 0.12)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user