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

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@ import {
Package,
Settings,
StopCircle,
RefreshCw,
CheckCircle,
XCircle,
Clock,
@ -282,16 +281,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
{/* Enhanced Actions Bar */}
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm">
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-5">
<button
onClick={() => onRefresh(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition-all duration-200 hover:border-slate-300 hover:bg-slate-100 hover:shadow-lg"
title="Refresh status"
>
<RefreshCw className="w-4 h-4 group-hover/btn:rotate-180 transition-transform duration-500" />
<span className="truncate">Refresh</span>
</button>
<div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">
<button
onClick={() => onViewEntries(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-emerald-500/40 bg-emerald-50 px-3 py-2.5 text-sm font-semibold text-emerald-700 transition-all duration-200 hover:border-emerald-500/60 hover:bg-emerald-100 hover:shadow-lg"

View File

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

View File

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