dev #1

Merged
ivanwu merged 15 commits from dev into main 2026-05-22 09:41:12 +00:00
174 changed files with 16142 additions and 3187 deletions
Showing only changes of commit 7d9545f827 - Show all commits

View File

@ -284,6 +284,7 @@ func setupRouter(
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.DeleteInstance).Methods(http.MethodDelete)
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/diagnostics", instanceHandler.GetInstanceDiagnostics).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/logs/stream", instanceHandler.StreamInstanceLogs).Methods(http.MethodGet)
// ===== Monitoring 路由 =====
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)

View File

@ -2,23 +2,25 @@ package dto
// CreateInstanceRequest 创建实例请求
type CreateInstanceRequest struct {
Name string `json:"name" binding:"required"`
Namespace string `json:"namespace" binding:"required"`
RegistryID string `json:"registryId" binding:"required"`
RegistryIDAlt string `json:"registry_id"`
Repository string `json:"repository" binding:"required"`
Tag string `json:"tag" binding:"required"`
Description string `json:"description"`
Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"`
Name string `json:"name" binding:"required"`
Namespace string `json:"namespace" binding:"required"`
RegistryID string `json:"registryId" binding:"required"`
RegistryIDAlt string `json:"registry_id"`
Repository string `json:"repository" binding:"required"`
Tag string `json:"tag" binding:"required"`
Description string `json:"description"`
Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"`
ValuesYAMLAlt string `json:"values_yaml"`
}
// UpdateInstanceRequest 更新实例请求
type UpdateInstanceRequest struct {
Version string `json:"version"`
Description string `json:"description"`
Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"`
Version string `json:"version"`
Description string `json:"description"`
Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"`
ValuesYAMLAlt string `json:"values_yaml"`
}
// Normalize 将多种命名风格的字段合并到统一字段
@ -26,6 +28,16 @@ func (r *CreateInstanceRequest) Normalize() {
if r.RegistryID == "" {
r.RegistryID = r.RegistryIDAlt
}
if r.ValuesYAML == "" {
r.ValuesYAML = r.ValuesYAMLAlt
}
}
// Normalize 将多种命名风格的字段合并到统一字段
func (r *UpdateInstanceRequest) Normalize() {
if r.ValuesYAML == "" {
r.ValuesYAML = r.ValuesYAMLAlt
}
}
// RollbackInstanceRequest 回滚实例请求

View File

@ -173,6 +173,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
req.Normalize()
// 获取现有实例
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
@ -281,6 +282,13 @@ func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.
return
}
tailLines = parsed
} else if raw := strings.TrimSpace(r.URL.Query().Get("tail_lines")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
respondError(w, http.StatusBadRequest, "Invalid tail_lines", "tail_lines must be a positive integer")
return
}
tailLines = parsed
}
diagnostics, err := h.instanceService.GetInstanceDiagnostics(r.Context(), clusterID, instanceID, tailLines)
@ -298,6 +306,71 @@ func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.
respondJSON(w, http.StatusOK, convertInstanceDiagnostics(diagnostics))
}
func (h *InstanceHandler) StreamInstanceLogs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
clusterID := vars["cluster_id"]
instanceID := vars["instance_id"]
podName := strings.TrimSpace(r.URL.Query().Get("pod"))
containerName := strings.TrimSpace(r.URL.Query().Get("container"))
if podName == "" || containerName == "" {
respondError(w, http.StatusBadRequest, "Missing required query parameter", "both 'pod' and 'container' are required")
return
}
tailLines := int64(200)
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
return
}
tailLines = parsed
}
lines, errs, err := h.instanceService.StreamInstanceLogs(r.Context(), clusterID, instanceID, podName, containerName, tailLines)
if err != nil {
status := http.StatusInternalServerError
switch err {
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
status = http.StatusNotFound
}
respondError(w, status, "Failed to stream instance logs", err.Error())
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
respondError(w, http.StatusInternalServerError, "Streaming not supported", "server does not support response flushing")
return
}
for {
select {
case <-r.Context().Done():
return
case line, open := <-lines:
if !open {
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
return
}
fmt.Fprintf(w, "data: %s\n\n", line)
flusher.Flush()
case err, open := <-errs:
if open && err != nil {
fmt.Fprintf(w, "data: [ERROR] %s\n\n", err.Error())
flusher.Flush()
}
}
}
}
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
for _, port := range entry.Ports {

View File

@ -1,6 +1,7 @@
package k8s
import (
"bufio"
"context"
"fmt"
"io"
@ -36,6 +37,23 @@ func (*MockDiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entit
}, nil
}
func (*MockDiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
lines := make(chan string, 10)
errs := make(chan error, 1)
go func() {
defer close(lines)
defer close(errs)
select {
case <-ctx.Done():
return
case lines <- "[mock] Streaming pod logs...":
case lines <- "[mock] Container started successfully":
case lines <- "[mock] Listening on :8080":
}
}()
return lines, errs, nil
}
func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
clientset, err := diagnosticsClientset(cluster)
if err != nil {
@ -73,6 +91,68 @@ func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.
}, nil
}
func (c *DiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
clientset, err := diagnosticsClientset(cluster)
if err != nil {
return nil, nil, err
}
if tailLines <= 0 {
tailLines = 200
}
if tailLines > 2000 {
tailLines = 2000
}
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
Container: containerName,
Follow: true,
TailLines: &tailLines,
})
stream, err := req.Stream(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to open log stream for %s/%s: %w", podName, containerName, err)
}
lines := make(chan string, 64)
errs := make(chan error, 1)
go func() {
defer close(lines)
defer close(errs)
defer func() { _ = stream.Close() }()
scanner := bufio.NewScanner(stream)
// Allow long lines; Kubernetes log entries can exceed the default 64 KiB
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
line := scanner.Text()
if line == "" {
continue
}
select {
case lines <- line:
case <-ctx.Done():
return
}
}
if err := scanner.Err(); err != nil {
select {
case errs <- err:
case <-ctx.Done():
}
}
}()
return lines, errs, nil
}
func diagnosticsClientset(cluster *entity.Cluster) (kubernetes.Interface, error) {
config, err := restConfigFromCluster(cluster)
if err != nil {

View File

@ -9,3 +9,9 @@ import (
type InstanceDiagnosticsClient interface {
GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error)
}
// PodLogStreamer streams pod log lines over channels. The caller reads from the
// lines channel until it is closed; errors are sent to the errs channel.
type PodLogStreamer interface {
StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error)
}

View File

@ -395,6 +395,28 @@ func (s *InstanceService) GetInstanceDiagnostics(ctx context.Context, clusterID,
return s.diagClient.GetDiagnostics(ctx, cluster, instance, tailLines)
}
func (s *InstanceService) StreamInstanceLogs(ctx context.Context, clusterID, instanceID, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
instance, err := s.GetInstance(ctx, instanceID)
if err != nil {
return nil, nil, entity.ErrInstanceNotFound
}
if instance.ClusterID != clusterID {
return nil, nil, entity.ErrInstanceNotFound
}
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
if err != nil {
return nil, nil, entity.ErrClusterNotFound
}
if s.diagClient == nil {
return nil, nil, fmt.Errorf("instance diagnostics client is not configured")
}
streamer, ok := s.diagClient.(repository.PodLogStreamer)
if !ok {
return nil, nil, fmt.Errorf("diagnostics client does not support log streaming")
}
return streamer.StreamPodLogs(ctx, cluster, instance.Namespace, podName, containerName, tailLines)
}
func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool {
if principal.IsAdmin() {
return true
@ -418,9 +440,12 @@ func enforceNamespaceValues(instance *entity.Instance) {
}
instance.Values["namespace"] = instance.Namespace
setExistingStringValue(instance.Values, "namespaceOverride", instance.Namespace)
setExistingStringValue(instance.Values, "namespace_override", instance.Namespace)
setExistingStringValue(instance.Values, "targetNamespace", instance.Namespace)
setExistingStringValue(instance.Values, "target_namespace", instance.Namespace)
setExistingNestedStringValue(instance.Values, "global", "namespace", instance.Namespace)
setExistingNestedStringValue(instance.Values, "global", "namespaceOverride", instance.Namespace)
setExistingNestedStringValue(instance.Values, "global", "namespace_override", instance.Namespace)
}
func setExistingStringValue(values map[string]interface{}, key, namespace string) {

View File

@ -28,9 +28,11 @@ const isTransformablePayload = (payload: unknown) => {
return typeof payload === "object";
};
const SKIP_RECURSE_KEYS = new Set(["values", "valuesYaml"]);
AXIOS_INSTANCE.interceptors.request.use((config) => {
if (isTransformablePayload(config.data)) {
config.data = keysToSnake(config.data);
config.data = keysToSnake(config.data, SKIP_RECURSE_KEYS);
}
if (isTransformablePayload(config.params)) {
config.params = keysToSnake(config.params);

View File

@ -76,7 +76,7 @@ import type {
PutRegistriesRegistryIdPathParameters,
} from './generated-orval/api.schemas';
import { customAxiosInstance } from './axios-mutator';
import { AXIOS_INSTANCE, customAxiosInstance } from './axios-mutator';
import {
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
@ -247,6 +247,88 @@ export const getInstanceDiagnostics = (
params: options?.tailLines ? { tailLines: options.tailLines } : undefined,
});
/**
* Stream pod logs via SSE from the backend.
* Returns an AbortController to cancel the stream at any time.
*/
export function streamInstanceLogs(
clusterId: string,
instanceId: string,
pod: string,
container: string,
tailLines: number = 200,
onLine: (line: string) => void,
onDone: () => void,
onError: (err: Error) => void,
): AbortController {
const controller = new AbortController();
const baseUrl = AXIOS_INSTANCE.defaults.baseURL ?? "/api/v1";
const authHeader = AXIOS_INSTANCE.defaults.headers.common["Authorization"] as string | undefined;
const params = new URLSearchParams({ pod, container, tailLines: String(tailLines) });
const url = `${baseUrl}/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/logs/stream?${params}`;
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (authHeader) {
headers["Authorization"] = authHeader;
}
fetch(url, { headers, signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
const text = await response.text().catch(() => response.statusText);
onError(new Error(`HTTP ${response.status}: ${text}`));
return;
}
const reader = response.body?.getReader();
if (!reader) {
onError(new Error("ReadableStream not supported"));
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last potentially-incomplete line in the buffer
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") {
onDone();
return;
}
if (data.startsWith("[ERROR]")) {
onError(new Error(data.slice(7).trim()));
continue;
}
onLine(data);
}
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// Stream was intentionally cancelled - not an error
return;
}
onError(err instanceof Error ? err : new Error(String(err)));
}
onDone();
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
onError(err instanceof Error ? err : new Error(String(err)));
});
return controller;
}
export const listRegistries = getRegistries;
export const createRegistry = postRegistries;
export const getRegistry = getRegistriesRegistryId;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, 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 { getInstanceDiagnostics, streamInstanceLogs, type InstanceDiagnosticsResponse, type InstanceResponse } from "@/api";
import { Button, Badge, LoadingState } from "@/shared/components";
import { formatApiError } from "@/shared/utils";
import { useToast } from "@/shared";
@ -17,6 +17,9 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
const [data, setData] = useState<InstanceDiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("summary");
const [streamingKey, setStreamingKey] = useState<string | null>(null);
const [streamingLines, setStreamingLines] = useState<string[]>([]);
const streamCtrlRef = useRef<AbortController | null>(null);
const loadDiagnostics = async () => {
if (!instance.clusterId || !instance.id) return;
@ -30,11 +33,48 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
}
};
const startStream = (pod: string, container: string) => {
// Stop any existing stream first
stopStream();
const key = `${pod}/${container}`;
setStreamingKey(key);
setStreamingLines([]);
const ctrl = streamInstanceLogs(
instance.clusterId!,
instance.id!,
pod,
container,
200,
(line) => setStreamingLines((prev) => [...prev, line]),
() => { setStreamingKey(null); },
(err) => { toastError(formatApiError(err) || "Stream error"); setStreamingKey(null); },
);
streamCtrlRef.current = ctrl;
};
const stopStream = () => {
if (streamCtrlRef.current) {
streamCtrlRef.current.abort();
streamCtrlRef.current = null;
}
setStreamingKey(null);
setStreamingLines([]);
};
useEffect(() => {
void loadDiagnostics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instance.clusterId, instance.id]);
// Cleanup stream on unmount
useEffect(() => {
return () => {
if (streamCtrlRef.current) {
streamCtrlRef.current.abort();
}
};
}, []);
const combinedLogs = useMemo(
() =>
(data?.logs ?? [])
@ -63,9 +103,15 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
</p>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" icon={RotateCw} onClick={loadDiagnostics} loading={loading}>
Refresh
</Button>
{streamingKey ? (
<Button type="button" variant="danger" size="sm" onClick={stopStream}>
Stop
</Button>
) : (
<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>
@ -92,7 +138,15 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
) : activeTab === "events" ? (
<EventsTab data={data} />
) : (
<LogsTab data={data} combinedLogs={combinedLogs} onCopy={copyLogs} />
<LogsTab
data={data}
combinedLogs={combinedLogs}
onCopy={copyLogs}
streamingKey={streamingKey}
streamingLines={streamingLines}
onStartStream={startStream}
onStopStream={stopStream}
/>
)}
</div>
</div>
@ -209,25 +263,85 @@ const EventsTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
</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>
const LogsTab = ({
data,
combinedLogs,
onCopy,
streamingKey,
streamingLines,
onStartStream,
onStopStream,
}: {
data: InstanceDiagnosticsResponse;
combinedLogs: string;
onCopy: () => void;
streamingKey: string | null;
streamingLines: string[];
onStartStream: (pod: string, container: string) => void;
onStopStream: () => void;
}) => {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (streamingKey && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [streamingLines, streamingKey]);
return (
<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>
))}
</div>
);
{(data.logs ?? []).length === 0 ? <EmptyLine text="No pod logs were returned." /> : null}
{(data.logs ?? []).map((entry) => {
const entryKey = `${entry.pod}/${entry.container}`;
const isStreaming = streamingKey === entryKey;
return (
<div key={entryKey} 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">{entryKey}</span>
{isStreaming && (
<span className="ml-auto inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-emerald-400">Live</span>
</span>
)}
{isStreaming ? (
<button
type="button"
onClick={onStopStream}
className="ml-2 rounded px-2 py-0.5 text-xs font-medium text-rose-400 hover:bg-rose-500/20 transition"
>
Stop
</button>
) : (
<button
type="button"
onClick={() => onStartStream(entry.pod!, entry.container!)}
className="ml-auto rounded px-2 py-0.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/20 transition"
>
Stream
</button>
)}
</div>
<pre
ref={isStreaming ? preRef : undefined}
className="max-h-96 overflow-auto p-4 text-xs leading-5 text-slate-100"
>
{isStreaming
? streamingLines.join("\n") || "Waiting for log data..."
: 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">

View File

@ -7,7 +7,6 @@ import {
Package,
Settings,
StopCircle,
RefreshCw,
CheckCircle,
XCircle,
Clock,
@ -282,16 +281,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
{/* Enhanced Actions Bar */}
<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 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" />
<span className="truncate">Refresh</span>
</button>
<div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">
<button
onClick={() => onViewEntries(instance)}
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"

View File

@ -11,6 +11,7 @@ import { useToast } from "@/shared";
import { createInstance, listClusters, getValuesSchema, getValuesYaml } from "@/api";
import type { CreateInstanceRequest, ClusterResponse } from "@/api";
import { useAuth } from "@/app/providers";
import { isAdminUser } from "@/app/providers/auth-model";
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
import {
Modal,
@ -63,6 +64,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
}) => {
const navigate = useNavigate();
const { user } = useAuth();
const isAdmin = isAdminUser(user);
const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterWithNamespacePolicy[]>([]);
@ -70,7 +72,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
// Form fields
const [clusterId, setClusterId] = useState("");
const [namespace, setNamespace] = useState(user?.namespace || "default");
const [namespace, setNamespace] = useState(isAdmin ? "default" : (user?.namespace || "default"));
const [instanceName, setInstanceName] = useState("");
const [description, setDescription] = useState("");
@ -97,8 +99,8 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
[clusters, clusterId]
);
const namespaceAccess = React.useMemo(
() => getNamespaceAccess(selectedCluster, user?.namespace),
[selectedCluster, user?.namespace]
() => getNamespaceAccess(selectedCluster, user?.namespace, user?.role),
[selectedCluster, user?.namespace, user?.role]
);
// Load clusters and schema on mount
@ -168,7 +170,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
setValuesYaml("");
setYamlError(null);
setValuesForm({});
setNamespace(user?.namespace || "default");
setNamespace(isAdmin ? "default" : (user?.namespace || "default"));
setInputMethod("quick");
};
@ -176,7 +178,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
if (!selectedCluster) {
return;
}
const access = getNamespaceAccess(selectedCluster, user?.namespace);
const access = getNamespaceAccess(selectedCluster, user?.namespace, user?.role);
if (access.defaultNamespace && namespace !== access.defaultNamespace) {
setNamespace(access.defaultNamespace);
} else if (!access.defaultNamespace && user?.namespace && namespace !== user.namespace) {
@ -344,7 +346,21 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
{/* Namespace */}
<FormField label="Namespace" required>
{namespaceAccess.allowedNamespaces.length > 0 ? (
{isAdmin ? (
<>
<Input
type="text"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
placeholder="default"
required
/>
<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" />
Admin can deploy to any namespace except system namespaces (kube-system, kube-public, kube-node-lease).
</div>
</>
) : namespaceAccess.allowedNamespaces.length > 0 ? (
<DropdownSelect
value={namespace}
onChange={(value) => setNamespace(value)}
@ -365,7 +381,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
disabled={namespaceAccess.readOnly}
/>
)}
{namespaceAccess.readOnly && (
{!isAdmin && 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.
@ -618,7 +634,15 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
return null;
};
const getNamespaceAccess = (cluster?: ClusterWithNamespacePolicy, userNamespace?: string) => {
const getNamespaceAccess = (cluster?: ClusterWithNamespacePolicy, userNamespace?: string, userRole?: string) => {
if (userRole === "admin") {
return {
allowedNamespaces: [] as string[],
defaultNamespace: "default",
readOnly: false,
};
}
const policy = cluster?.namespacePolicy;
const policyObject = isRecord(policy) ? policy : undefined;
const allowedNamespaces = uniqueStrings([

View File

@ -56,14 +56,14 @@ export function keysToCamel<T = any>(obj: any): T {
* @param obj - 要转换的对象(可能包含嵌套对象和数组)
* @returns 转换后的对象
*/
export function keysToSnake<T = any>(obj: any): T {
export function keysToSnake<T = any>(obj: any, skipRecurseKeys?: Set<string>): T {
if (obj === null || obj === undefined) {
return obj;
}
// 处理数组
if (Array.isArray(obj)) {
return obj.map(item => keysToSnake(item)) as any;
return obj.map(item => keysToSnake(item, skipRecurseKeys)) as any;
}
// 处理普通对象
@ -72,7 +72,11 @@ export function keysToSnake<T = any>(obj: any): T {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const snakeKey = camelToSnake(key);
converted[snakeKey] = keysToSnake(obj[key]);
if (skipRecurseKeys?.has(key)) {
converted[snakeKey] = obj[key];
} else {
converted[snakeKey] = keysToSnake(obj[key], skipRecurseKeys);
}
}
}
return converted;

View File

@ -6,3 +6,4 @@
- Multi-cluster tenant resources must be scoped by `(workspace_id, cluster_id)`. Do not infer the target cluster from list order; user/workspace defaults, kubeconfig issuance, namespace creation, ResourceQuota, and deploy must all use the same selected cluster.
- For real Helm smoke tests, wait for platform instance deletion to remove the DB record before deleting the Kubernetes namespace manually. Deleting the namespace too early can make the async Helm uninstall mark the instance failed.
- When embedding Helm, setting `actionConfig.Init(..., namespace, ...)` and `Install.Namespace` is not enough. The custom `RESTClientGetter` must also override the raw kubeconfig loader namespace, or manifests without `metadata.namespace` can be created in the kubeconfig context namespace such as `default`.
- **Axios keysToSnake recursively converts ALL object keys including user-provided values map.** This silently renames Helm chart values (gpuMem → gpu_mem) causing chart to ignore user settings. Fix: skip recursion for known data fields (values, valuesYaml) while still converting field names. Backend DTOs must provide dual json tags (camelCase + snake_case) with Normalize() fallback.