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:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user