feat: fix YAML field conversion, admin namespace, streaming logs, and vllm-serve deploy

- Fix Axios keysToSnake converting user values map keys (gpuMem -> gpu_mem)
  - Add skipRecurseKeys to keysToSnake for values/valuesYaml fields
  - Add values_yaml alt json tag and Normalize() in DTOs
  - Check both camelCase/snake_case in enforceNamespaceValues
  - Read both tailLines/tail_lines query param for diagnostics
- Admin users can freely choose namespace in LaunchModal (free-text input)
  - Block only kube-system/kube-public/kube-node-lease for admin
  - Regular users keep existing namespace restrictions
- Add SSE streaming pod logs endpoint (backend + frontend)
  - New PodLogStreamer interface and K8s Follow:true implementation
  - SSE handler with text/event-stream output
  - Frontend DiagnosticsModal: Stream button, auto-scroll, live indicator
- Remove per-card Refresh button from InstanceCard (redundant with page refresh)
- Deploy bge-m3 on vllm-serve 0.6.0 (gpuMem=10000, status=deployed)
This commit is contained in:
Ivan087
2026-05-12 16:50:25 +08:00
parent 7f238a3168
commit 7d9545f827
13 changed files with 475 additions and 61 deletions

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}", 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}/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}/diagnostics", instanceHandler.GetInstanceDiagnostics).Methods(http.MethodGet)
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/logs/stream", instanceHandler.StreamInstanceLogs).Methods(http.MethodGet)
// ===== Monitoring 路由 ===== // ===== Monitoring 路由 =====
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet) protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)

View File

@ -2,23 +2,25 @@ package dto
// CreateInstanceRequest 创建实例请求 // CreateInstanceRequest 创建实例请求
type CreateInstanceRequest struct { type CreateInstanceRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Namespace string `json:"namespace" binding:"required"` Namespace string `json:"namespace" binding:"required"`
RegistryID string `json:"registryId" binding:"required"` RegistryID string `json:"registryId" binding:"required"`
RegistryIDAlt string `json:"registry_id"` RegistryIDAlt string `json:"registry_id"`
Repository string `json:"repository" binding:"required"` Repository string `json:"repository" binding:"required"`
Tag string `json:"tag" binding:"required"` Tag string `json:"tag" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Values map[string]interface{} `json:"values"` Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"` ValuesYAML string `json:"valuesYaml"`
ValuesYAMLAlt string `json:"values_yaml"`
} }
// UpdateInstanceRequest 更新实例请求 // UpdateInstanceRequest 更新实例请求
type UpdateInstanceRequest struct { type UpdateInstanceRequest struct {
Version string `json:"version"` Version string `json:"version"`
Description string `json:"description"` Description string `json:"description"`
Values map[string]interface{} `json:"values"` Values map[string]interface{} `json:"values"`
ValuesYAML string `json:"valuesYaml"` ValuesYAML string `json:"valuesYaml"`
ValuesYAMLAlt string `json:"values_yaml"`
} }
// Normalize 将多种命名风格的字段合并到统一字段 // Normalize 将多种命名风格的字段合并到统一字段
@ -26,6 +28,16 @@ func (r *CreateInstanceRequest) Normalize() {
if r.RegistryID == "" { if r.RegistryID == "" {
r.RegistryID = r.RegistryIDAlt r.RegistryID = r.RegistryIDAlt
} }
if r.ValuesYAML == "" {
r.ValuesYAML = r.ValuesYAMLAlt
}
}
// Normalize 将多种命名风格的字段合并到统一字段
func (r *UpdateInstanceRequest) Normalize() {
if r.ValuesYAML == "" {
r.ValuesYAML = r.ValuesYAMLAlt
}
} }
// RollbackInstanceRequest 回滚实例请求 // 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()) respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return return
} }
req.Normalize()
// 获取现有实例 // 获取现有实例
instance, err := h.instanceService.GetInstance(r.Context(), instanceID) instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
@ -281,6 +282,13 @@ func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.
return return
} }
tailLines = parsed 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) 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)) 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 { func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports)) portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
for _, port := range entry.Ports { for _, port := range entry.Ports {

View File

@ -1,6 +1,7 @@
package k8s package k8s
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"io" "io"
@ -36,6 +37,23 @@ func (*MockDiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entit
}, nil }, 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) { func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
clientset, err := diagnosticsClientset(cluster) clientset, err := diagnosticsClientset(cluster)
if err != nil { if err != nil {
@ -73,6 +91,68 @@ func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.
}, nil }, 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) { func diagnosticsClientset(cluster *entity.Cluster) (kubernetes.Interface, error) {
config, err := restConfigFromCluster(cluster) config, err := restConfigFromCluster(cluster)
if err != nil { if err != nil {

View File

@ -9,3 +9,9 @@ import (
type InstanceDiagnosticsClient interface { type InstanceDiagnosticsClient interface {
GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) 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) 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 { func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool {
if principal.IsAdmin() { if principal.IsAdmin() {
return true return true
@ -418,9 +440,12 @@ func enforceNamespaceValues(instance *entity.Instance) {
} }
instance.Values["namespace"] = instance.Namespace instance.Values["namespace"] = instance.Namespace
setExistingStringValue(instance.Values, "namespaceOverride", instance.Namespace) setExistingStringValue(instance.Values, "namespaceOverride", instance.Namespace)
setExistingStringValue(instance.Values, "namespace_override", instance.Namespace)
setExistingStringValue(instance.Values, "targetNamespace", 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", "namespace", instance.Namespace)
setExistingNestedStringValue(instance.Values, "global", "namespaceOverride", 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) { func setExistingStringValue(values map[string]interface{}, key, namespace string) {

View File

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

View File

@ -76,7 +76,7 @@ import type {
PutRegistriesRegistryIdPathParameters, PutRegistriesRegistryIdPathParameters,
} from './generated-orval/api.schemas'; } from './generated-orval/api.schemas';
import { customAxiosInstance } from './axios-mutator'; import { AXIOS_INSTANCE, customAxiosInstance } from './axios-mutator';
import { import {
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum, GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
@ -247,6 +247,88 @@ export const getInstanceDiagnostics = (
params: options?.tailLines ? { tailLines: options.tailLines } : undefined, 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 listRegistries = getRegistries;
export const createRegistry = postRegistries; export const createRegistry = postRegistries;
export const getRegistry = getRegistriesRegistryId; 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 { 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 { Button, Badge, LoadingState } from "@/shared/components";
import { formatApiError } from "@/shared/utils"; import { formatApiError } from "@/shared/utils";
import { useToast } from "@/shared"; import { useToast } from "@/shared";
@ -17,6 +17,9 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
const [data, setData] = useState<InstanceDiagnosticsResponse | null>(null); const [data, setData] = useState<InstanceDiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("summary"); 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 () => { const loadDiagnostics = async () => {
if (!instance.clusterId || !instance.id) return; 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(() => { useEffect(() => {
void loadDiagnostics(); void loadDiagnostics();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [instance.clusterId, instance.id]); }, [instance.clusterId, instance.id]);
// Cleanup stream on unmount
useEffect(() => {
return () => {
if (streamCtrlRef.current) {
streamCtrlRef.current.abort();
}
};
}, []);
const combinedLogs = useMemo( const combinedLogs = useMemo(
() => () =>
(data?.logs ?? []) (data?.logs ?? [])
@ -63,9 +103,15 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" icon={RotateCw} onClick={loadDiagnostics} loading={loading}> {streamingKey ? (
Refresh <Button type="button" variant="danger" size="sm" onClick={stopStream}>
</Button> 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"> <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" /> <X className="h-5 w-5" />
</button> </button>
@ -92,7 +138,15 @@ export const DiagnosticsModal: React.FC<DiagnosticsModalProps> = ({ instance, on
) : activeTab === "events" ? ( ) : activeTab === "events" ? (
<EventsTab data={data} /> <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>
</div> </div>
@ -209,25 +263,85 @@ const EventsTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
</div> </div>
); );
const LogsTab = ({ data, combinedLogs, onCopy }: { data: InstanceDiagnosticsResponse; combinedLogs: string; onCopy: () => void }) => ( const LogsTab = ({
<div className="space-y-4"> data,
<div className="flex justify-end"> combinedLogs,
<Button type="button" variant="secondary" size="sm" icon={Copy} onClick={onCopy} disabled={!combinedLogs}> onCopy,
Copy Logs streamingKey,
</Button> streamingLines,
</div> onStartStream,
{(data.logs ?? []).length === 0 ? <EmptyLine text="No pod logs were returned." /> : null} onStopStream,
{(data.logs ?? []).map((entry) => ( }: {
<div key={`${entry.pod}-${entry.container}`} className="overflow-hidden rounded-lg border border-slate-800 bg-slate-950"> data: InstanceDiagnosticsResponse;
<div className="flex items-center gap-2 border-b border-slate-800 px-4 py-2 text-xs text-slate-300"> combinedLogs: string;
<FileText className="h-3.5 w-3.5" /> onCopy: () => void;
<span className="font-mono">{entry.pod}/{entry.container}</span> streamingKey: string | null;
</div> streamingLines: string[];
<pre className="max-h-96 overflow-auto p-4 text-xs leading-5 text-slate-100">{entry.error || entry.log || ""}</pre> 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}
</div> {(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 }) => ( 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="rounded-lg border border-slate-200 bg-white p-4">

View File

@ -7,7 +7,6 @@ import {
Package, Package,
Settings, Settings,
StopCircle, StopCircle,
RefreshCw,
CheckCircle, CheckCircle,
XCircle, XCircle,
Clock, Clock,
@ -282,16 +281,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
{/* Enhanced Actions Bar */} {/* 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="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"> <div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">
<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>
<button <button
onClick={() => onViewEntries(instance)} 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" 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 { createInstance, listClusters, getValuesSchema, getValuesYaml } from "@/api";
import type { CreateInstanceRequest, ClusterResponse } from "@/api"; import type { CreateInstanceRequest, ClusterResponse } from "@/api";
import { useAuth } from "@/app/providers"; import { useAuth } from "@/app/providers";
import { isAdminUser } from "@/app/providers/auth-model";
import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils"; import { ClusterErrors, InstanceErrors, SuccessMessages, ValidationErrors, formatApiError } from "@/shared/utils";
import { import {
Modal, Modal,
@ -63,6 +64,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = isAdminUser(user);
const { success, error: toastError, info: toastInfo } = useToast(); const { success, error: toastError, info: toastInfo } = useToast();
const [clusters, setClusters] = useState<ClusterWithNamespacePolicy[]>([]); const [clusters, setClusters] = useState<ClusterWithNamespacePolicy[]>([]);
@ -70,7 +72,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
// Form fields // Form fields
const [clusterId, setClusterId] = useState(""); 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 [instanceName, setInstanceName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@ -97,8 +99,8 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
[clusters, clusterId] [clusters, clusterId]
); );
const namespaceAccess = React.useMemo( const namespaceAccess = React.useMemo(
() => getNamespaceAccess(selectedCluster, user?.namespace), () => getNamespaceAccess(selectedCluster, user?.namespace, user?.role),
[selectedCluster, user?.namespace] [selectedCluster, user?.namespace, user?.role]
); );
// Load clusters and schema on mount // Load clusters and schema on mount
@ -168,7 +170,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
setValuesYaml(""); setValuesYaml("");
setYamlError(null); setYamlError(null);
setValuesForm({}); setValuesForm({});
setNamespace(user?.namespace || "default"); setNamespace(isAdmin ? "default" : (user?.namespace || "default"));
setInputMethod("quick"); setInputMethod("quick");
}; };
@ -176,7 +178,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
if (!selectedCluster) { if (!selectedCluster) {
return; return;
} }
const access = getNamespaceAccess(selectedCluster, user?.namespace); const access = getNamespaceAccess(selectedCluster, user?.namespace, user?.role);
if (access.defaultNamespace && namespace !== access.defaultNamespace) { if (access.defaultNamespace && namespace !== access.defaultNamespace) {
setNamespace(access.defaultNamespace); setNamespace(access.defaultNamespace);
} else if (!access.defaultNamespace && user?.namespace && namespace !== user.namespace) { } else if (!access.defaultNamespace && user?.namespace && namespace !== user.namespace) {
@ -344,7 +346,21 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
{/* Namespace */} {/* Namespace */}
<FormField label="Namespace" required> <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 <DropdownSelect
value={namespace} value={namespace}
onChange={(value) => setNamespace(value)} onChange={(value) => setNamespace(value)}
@ -365,7 +381,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
disabled={namespaceAccess.readOnly} 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"> <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" /> <AlertCircle className="h-3.5 w-3.5" />
Namespace is controlled by your workspace policy. Namespace is controlled by your workspace policy.
@ -618,7 +634,15 @@ const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
return 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 policy = cluster?.namespacePolicy;
const policyObject = isRecord(policy) ? policy : undefined; const policyObject = isRecord(policy) ? policy : undefined;
const allowedNamespaces = uniqueStrings([ const allowedNamespaces = uniqueStrings([

View File

@ -56,14 +56,14 @@ export function keysToCamel<T = any>(obj: any): T {
* @param obj - 要转换的对象(可能包含嵌套对象和数组) * @param obj - 要转换的对象(可能包含嵌套对象和数组)
* @returns 转换后的对象 * @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) { if (obj === null || obj === undefined) {
return obj; return obj;
} }
// 处理数组 // 处理数组
if (Array.isArray(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) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
const snakeKey = camelToSnake(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; 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. - 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. - 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`. - 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.