fix: scale replicas in response, K8s metrics client, quota precheck, auth tests

- Add GetMetrics method to MetricsClient interface and implement cluster metrics API
- Add QuotaPrecheck service for validating resource quotas before deployment
- Add auth DTO with role/permission models and auth handler tests
- Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics
- Update workspace handler with GetWorkspace endpoint and shared-user list
- Fix monitoring handler to use correct service method name
- Add tail_lines fallback in instance handler for snake_case query params
- Update nginx config for SSE log streaming support (no buffering)
- Add comprehensive test coverage: auth_service_test, auth_handler_test,
  auth_dto_test, metrics_client_test, quota_precheck_test
- Update error messages for quota validation and instance operations
- ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit
- InstanceCard: correctly disable scale-minus when replicas <= 0
- SidebarLayout: add hover transition for sidebar items
- Update todo.md and lessons.md with latest fixes
This commit is contained in:
Ivan087
2026-05-20 16:56:29 +08:00
parent 8f90cf0f0d
commit 33ddaf97db
59 changed files with 4805 additions and 457 deletions

View File

@ -79,6 +79,12 @@ func main() {
passwordHasher,
tokenGenerator,
)
authService.SetUserLifecycleCleanup(
repos.InstanceRepo,
repos.ClusterRepo,
repos.BindingRepo,
repos.TenantKubeClient,
)
clusterService := service.NewClusterService(
repos.ClusterRepo,
@ -106,10 +112,13 @@ func main() {
instanceService.SetDiagnosticsClient(repos.DiagnosticsClient)
instanceService.SetTenantProvisioning(repos.WorkspaceRepo, repos.TenantKubeClient)
instanceService.SetScaleClient(k8s.NewScaleClient())
instanceService.SetUserRepository(repos.UserRepo)
monitoringService := service.NewMonitoringService(
repos.ClusterRepo,
repos.MetricsClient,
repos.InstanceRepo,
repos.UserRepo,
)
workspaceService := service.NewWorkspaceService(
@ -243,8 +252,8 @@ func setupRouter(
api := router.PathPrefix("/api/v1").Subrouter()
// ===== 认证路由 =====
api.HandleFunc("/auth/login", authHandler.Login)
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
api.HandleFunc("/auth/login", authHandler.Login).Methods(http.MethodPost)
api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods(http.MethodPost)
protected := api.PathPrefix("").Subrouter()
protected.Use(authMiddleware(authService))
@ -262,6 +271,8 @@ func setupRouter(
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
protected.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}/stats", monitoringHandler.GetClusterStats).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}/kubeconfig", workspaceHandler.IssueClusterKubeconfig).Methods(http.MethodGet)
// ===== Registry 路由 =====
protected.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
@ -273,7 +284,9 @@ func setupRouter(
// ===== Artifact 路由 =====
protected.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
protected.HandleFunc("/repositories/{repository_name:.+}/tags", artifactHandler.ListRepositoryTags).Methods(http.MethodGet)
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/tags", artifactHandler.ListRepositoryTags).Methods(http.MethodGet)
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}", artifactHandler.GetArtifact).Methods(http.MethodGet)
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-yaml", artifactHandler.GetArtifactValuesYAML).Methods(http.MethodGet)
@ -293,6 +306,7 @@ func setupRouter(
// ===== Monitoring 路由 =====
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
protected.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
protected.HandleFunc("/monitoring/clusters/{cluster_id}/metrics", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
protected.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
protected.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
@ -358,15 +372,16 @@ func loggingMiddleware(next http.Handler) http.Handler {
// corsMiddleware CORS 中间件
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 设置 CORS 头
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
if origin != "" {
w.Header().Add("Vary", "Origin")
if corsOriginAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
// 处理 OPTIONS 预检请求
@ -378,3 +393,47 @@ func corsMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func corsOriginAllowed(origin string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
return false
}
for _, allowed := range corsAllowedOrigins() {
if origin == allowed {
return true
}
}
return false
}
func corsAllowedOrigins() []string {
configured := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
if configured == "" {
configured = strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS"))
}
if configured == "" {
return []string{
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8080",
"http://localhost:18080",
"http://localhost:18081",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:8080",
"http://127.0.0.1:18080",
"http://127.0.0.1:18081",
"http://10.6.80.114:18080",
}
}
origins := make([]string, 0)
for _, origin := range strings.Split(configured, ",") {
origin = strings.TrimSpace(origin)
if origin == "" || origin == "*" {
continue
}
origins = append(origins, origin)
}
return origins
}

View File

@ -0,0 +1,50 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestCORSMiddlewareAllowsDefaultLocalhostOrigin(t *testing.T) {
t.Setenv("CORS_ALLOWED_ORIGINS", "")
t.Setenv("ALLOWED_ORIGINS", "")
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", "http://localhost:5173")
rec := httptest.NewRecorder()
corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("expected localhost origin to be allowed, got %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected credentials header for allowed origin, got %q", got)
}
}
func TestCORSMiddlewareDoesNotReflectDisallowedOrigin(t *testing.T) {
t.Setenv("CORS_ALLOWED_ORIGINS", "https://app.example.com")
t.Setenv("ALLOWED_ORIGINS", "")
req := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/login", nil)
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("preflight should not call next handler")
})).ServeHTTP(rec, req)
if got := rec.Code; got != http.StatusNoContent {
t.Fatalf("expected preflight status %d, got %d", http.StatusNoContent, got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("expected disallowed origin not to be reflected, got %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected credentials header to be omitted for disallowed origin, got %q", got)
}
}