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

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

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

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

View File

@ -0,0 +1,291 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// StorageHandler Storage Backend Handler
type StorageHandler struct {
storageService *service.StorageService
}
// NewStorageHandler 创建 Storage Handler
func NewStorageHandler(storageService *service.StorageService) *StorageHandler {
return &StorageHandler{
storageService: storageService,
}
}
// CreateStorage 创建存储后端
// @Summary 创建存储后端
// @Description 新增存储后端配置NFS/PV/hostPath
// @Tags Storage
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateStorageRequest true "存储后端信息"
// @Success 201 {object} dto.StorageResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /storage-backends [post]
func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) {
var req dto.CreateStorageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 获取用户信息
workspaceID := r.Header.Get("X-Workspace-ID")
ownerID := r.Header.Get("X-User-ID")
// 构建配置
storageType := entity.StorageType(req.Type)
config := entity.StorageConfig{}
switch storageType {
case entity.StorageTypeNFS:
config.NFS = &entity.NFSConfig{
Server: req.NFS.Server,
Path: req.NFS.Path,
}
case entity.StorageTypePV:
config.PV = &entity.PVConfig{
StorageClassName: req.PV.StorageClassName,
Capacity: req.PV.Capacity,
AccessModes: req.PV.AccessModes,
}
case entity.StorageTypeHostPath:
config.HostPath = &entity.HostPathConfig{
Path: req.HostPath.Path,
}
}
// 调用领域服务
storage, err := h.storageService.Create(
r.Context(),
workspaceID,
ownerID,
req.Name,
storageType,
config,
req.Description,
req.IsDefault,
req.IsShared,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error())
return
}
// 返回响应
response := toStorageResponse(storage)
respondJSON(w, http.StatusCreated, response)
}
// GetStorage 获取存储后端详情
// @Summary 获取存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Success 200 {object} dto.StorageResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [get]
func (h *StorageHandler) GetStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
storage, err := h.storageService.GetByID(r.Context(), storageID)
if err != nil {
respondError(w, http.StatusNotFound, "Storage backend not found", err.Error())
return
}
response := toStorageResponse(storage)
respondJSON(w, http.StatusOK, response)
}
// GetAllStorage 获取所有存储后端
// @Summary 列出所有存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.StorageResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /storage-backends [get]
func (h *StorageHandler) GetAllStorage(w http.ResponseWriter, r *http.Request) {
// 获取 workspace_id从 JWT
workspaceID := r.Header.Get("X-Workspace-ID")
role := r.Header.Get("X-User-Role")
var storages []*entity.StorageBackend
var err error
// Admin 可以看到所有,其他用户只看自己 workspace + 共享的
if role == "admin" {
storages, err = h.storageService.List(r.Context())
} else if workspaceID != "" {
// 获取 workspace 的存储 + 共享存储
workspaceStorages, _ := h.storageService.GetByWorkspace(r.Context(), workspaceID)
sharedStorages, _ := h.storageService.GetShared(r.Context())
// 合并去重
seen := make(map[string]bool)
for _, s := range workspaceStorages {
if !seen[s.ID] {
storages = append(storages, s)
seen[s.ID] = true
}
}
for _, s := range sharedStorages {
if !seen[s.ID] {
storages = append(storages, s)
seen[s.ID] = true
}
}
} else {
// 没有 workspace 的用户只能看到共享存储
storages, err = h.storageService.GetShared(r.Context())
}
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list storage backends", err.Error())
return
}
responses := make([]*dto.StorageResponse, 0, len(storages))
for _, storage := range storages {
responses = append(responses, toStorageResponse(storage))
}
respondJSON(w, http.StatusOK, responses)
}
// UpdateStorage 更新存储后端
// @Summary 更新存储后端
// @Tags Storage
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Param request body dto.UpdateStorageRequest true "更新内容"
// @Success 200 {object} dto.StorageResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [put]
func (h *StorageHandler) UpdateStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
var req dto.UpdateStorageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 构建配置
var storageType entity.StorageType
if req.Type != "" {
storageType = entity.StorageType(req.Type)
}
config := entity.StorageConfig{}
if storageType == entity.StorageTypeNFS && (req.NFS.Server != "" || req.NFS.Path != "") {
config.NFS = &entity.NFSConfig{
Server: req.NFS.Server,
Path: req.NFS.Path,
}
} else if storageType == entity.StorageTypePV && req.PV.StorageClassName != "" {
config.PV = &entity.PVConfig{
StorageClassName: req.PV.StorageClassName,
Capacity: req.PV.Capacity,
AccessModes: req.PV.AccessModes,
}
} else if storageType == entity.StorageTypeHostPath && req.HostPath.Path != "" {
config.HostPath = &entity.HostPathConfig{
Path: req.HostPath.Path,
}
}
storage, err := h.storageService.Update(
r.Context(),
storageID,
req.Name,
req.Description,
storageType,
config,
req.IsDefault,
req.IsShared,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to update storage backend", err.Error())
return
}
response := toStorageResponse(storage)
respondJSON(w, http.StatusOK, response)
}
// DeleteStorage 删除存储后端
// @Summary 删除存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Success 204 {string} string "No Content"
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [delete]
func (h *StorageHandler) DeleteStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
if err := h.storageService.Delete(r.Context(), storageID); err != nil {
respondError(w, http.StatusNotFound, "Failed to delete storage backend", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// toStorageResponse 转换为响应 DTO
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
config := dto.StorageConfigDTO{}
if storage.Config.NFS != nil {
config.NFS = &dto.NFSConfigDTO{
Server: storage.Config.NFS.Server,
Path: storage.Config.NFS.Path,
}
}
if storage.Config.PV != nil {
config.PV = &dto.PVConfigDTO{
StorageClassName: storage.Config.PV.StorageClassName,
Capacity: storage.Config.PV.Capacity,
AccessModes: storage.Config.PV.AccessModes,
}
}
if storage.Config.HostPath != nil {
config.HostPath = &dto.HostPathConfigDTO{
Path: storage.Config.HostPath.Path,
}
}
return &dto.StorageResponse{
ID: storage.ID,
WorkspaceID: storage.WorkspaceID,
OwnerID: storage.OwnerID,
Name: storage.Name,
Type: string(storage.Type),
Config: config,
Description: storage.Description,
IsDefault: storage.IsDefault,
IsShared: storage.IsShared,
CreatedAt: storage.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: storage.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}