From 7d9545f8273ebcf4b7ecf02377b9f2737b6771d3 Mon Sep 17 00:00:00 2001
From: Ivan087
Date: Tue, 12 May 2026 16:50:25 +0800
Subject: [PATCH] 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)
---
backend/cmd/api/main.go | 1 +
.../adapter/input/http/dto/instance_dto.go | 38 ++--
.../input/http/rest/instance_handler.go | 73 ++++++++
.../adapter/output/k8s/diagnostics_client.go | 80 +++++++++
.../repository/instance_diagnostics_client.go | 6 +
.../domain/service/instance_service.go | 25 +++
frontend/src/api/axios-mutator.ts | 4 +-
frontend/src/api/index.ts | 84 ++++++++-
.../instances/components/DiagnosticsModal.tsx | 162 +++++++++++++++---
.../instances/components/InstanceCard.tsx | 12 +-
.../registries/components/LaunchModal.tsx | 40 ++++-
frontend/src/shared/utils/case-converter.ts | 10 +-
tasks/lessons.md | 1 +
13 files changed, 475 insertions(+), 61 deletions(-)
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 4f1a3be..641c4b0 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -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)
diff --git a/backend/internal/adapter/input/http/dto/instance_dto.go b/backend/internal/adapter/input/http/dto/instance_dto.go
index b354055..8c70de5 100644
--- a/backend/internal/adapter/input/http/dto/instance_dto.go
+++ b/backend/internal/adapter/input/http/dto/instance_dto.go
@@ -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 回滚实例请求
diff --git a/backend/internal/adapter/input/http/rest/instance_handler.go b/backend/internal/adapter/input/http/rest/instance_handler.go
index 7592a06..d3f4457 100644
--- a/backend/internal/adapter/input/http/rest/instance_handler.go
+++ b/backend/internal/adapter/input/http/rest/instance_handler.go
@@ -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 {
diff --git a/backend/internal/adapter/output/k8s/diagnostics_client.go b/backend/internal/adapter/output/k8s/diagnostics_client.go
index 146d45f..8ff484f 100644
--- a/backend/internal/adapter/output/k8s/diagnostics_client.go
+++ b/backend/internal/adapter/output/k8s/diagnostics_client.go
@@ -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 {
diff --git a/backend/internal/domain/repository/instance_diagnostics_client.go b/backend/internal/domain/repository/instance_diagnostics_client.go
index f04b98a..2f65c2b 100644
--- a/backend/internal/domain/repository/instance_diagnostics_client.go
+++ b/backend/internal/domain/repository/instance_diagnostics_client.go
@@ -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)
+}
diff --git a/backend/internal/domain/service/instance_service.go b/backend/internal/domain/service/instance_service.go
index 9532648..d714b4f 100644
--- a/backend/internal/domain/service/instance_service.go
+++ b/backend/internal/domain/service/instance_service.go
@@ -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) {
diff --git a/frontend/src/api/axios-mutator.ts b/frontend/src/api/axios-mutator.ts
index ce47bca..5d8d62e 100644
--- a/frontend/src/api/axios-mutator.ts
+++ b/frontend/src/api/axios-mutator.ts
@@ -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);
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 0e55bfb..c9da3a1 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -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 = { 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;
diff --git a/frontend/src/features/artifact/instances/components/DiagnosticsModal.tsx b/frontend/src/features/artifact/instances/components/DiagnosticsModal.tsx
index 9d15eb8..808b00b 100644
--- a/frontend/src/features/artifact/instances/components/DiagnosticsModal.tsx
+++ b/frontend/src/features/artifact/instances/components/DiagnosticsModal.tsx
@@ -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 = ({ instance, on
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("summary");
+ const [streamingKey, setStreamingKey] = useState(null);
+ const [streamingLines, setStreamingLines] = useState([]);
+ const streamCtrlRef = useRef(null);
const loadDiagnostics = async () => {
if (!instance.clusterId || !instance.id) return;
@@ -30,11 +33,48 @@ export const DiagnosticsModal: React.FC = ({ 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 = ({ instance, on
-
+ {streamingKey ? (
+
+ ) : (
+
+ )}
@@ -92,7 +138,15 @@ export const DiagnosticsModal: React.FC = ({ instance, on
) : activeTab === "events" ? (
) : (
-
+
)}
@@ -209,25 +263,85 @@ const EventsTab = ({ data }: { data: InstanceDiagnosticsResponse }) => (
);
-const LogsTab = ({ data, combinedLogs, onCopy }: { data: InstanceDiagnosticsResponse; combinedLogs: string; onCopy: () => void }) => (
-
-
-
-
- {(data.logs ?? []).length === 0 ?
: null}
- {(data.logs ?? []).map((entry) => (
-
-
-
- {entry.pod}/{entry.container}
-
-
{entry.error || entry.log || ""}
+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
(null);
+
+ useEffect(() => {
+ if (streamingKey && preRef.current) {
+ preRef.current.scrollTop = preRef.current.scrollHeight;
+ }
+ }, [streamingLines, streamingKey]);
+
+ return (
+
+
+
- ))}
-
-);
+ {(data.logs ?? []).length === 0 ? : null}
+ {(data.logs ?? []).map((entry) => {
+ const entryKey = `${entry.pod}/${entry.container}`;
+ const isStreaming = streamingKey === entryKey;
+ return (
+
+
+
+ {entryKey}
+ {isStreaming && (
+
+
+ Live
+
+ )}
+ {isStreaming ? (
+
+ ) : (
+
+ )}
+
+
+ {isStreaming
+ ? streamingLines.join("\n") || "Waiting for log data..."
+ : entry.error || entry.log || ""}
+
+
+ );
+ })}
+
+ );
+};
const MetricCard = ({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: number }) => (
diff --git a/frontend/src/features/artifact/instances/components/InstanceCard.tsx b/frontend/src/features/artifact/instances/components/InstanceCard.tsx
index bc77572..5feb7bd 100644
--- a/frontend/src/features/artifact/instances/components/InstanceCard.tsx
+++ b/frontend/src/features/artifact/instances/components/InstanceCard.tsx
@@ -7,7 +7,6 @@ import {
Package,
Settings,
StopCircle,
- RefreshCw,
CheckCircle,
XCircle,
Clock,
@@ -282,16 +281,7 @@ export const InstanceCard: React.FC
= ({
{/* Enhanced Actions Bar */}
-
-
-
+