dev #1
@ -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)
|
||||
|
||||
@ -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 回滚实例请求
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user