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

@ -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"