fix: scale replicas in response, K8s metrics client, quota precheck, auth tests
- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
This commit is contained in:
@ -3,7 +3,7 @@
|
||||
* Export configured API client, generated functions, and friendly aliases.
|
||||
*/
|
||||
|
||||
type AxiosOptions<T extends (...args: any) => any> = Parameters<T>[2];
|
||||
type AxiosOptions<T extends (...args: never[]) => unknown> = Parameters<T>[2];
|
||||
|
||||
import {
|
||||
deleteClustersClusterId,
|
||||
@ -143,7 +143,10 @@ export type CreateRegistryRequest = GeneratedCreateRegistryRequest;
|
||||
export type UpdateRegistryRequest = GeneratedUpdateRegistryRequest;
|
||||
export type RegistryHealthResponse = GeneratedRegistryHealthResponse;
|
||||
|
||||
export type InstanceResponse = GeneratedInstanceResponse;
|
||||
export type InstanceResponse = GeneratedInstanceResponse & {
|
||||
ownerId?: string;
|
||||
ownerUsername?: string;
|
||||
};
|
||||
export type CreateInstanceRequest = GeneratedCreateInstanceRequest;
|
||||
export type UpdateInstanceRequest = GeneratedUpdateInstanceRequest;
|
||||
export type InstanceEntry = GeneratedInstanceEntry;
|
||||
@ -242,7 +245,7 @@ export const scaleInstance = (
|
||||
instanceId: string,
|
||||
body: { replicas: number; workload?: string },
|
||||
) => {
|
||||
return customAxiosInstance<{ instance: any; replicas: number; message: string }>({
|
||||
return customAxiosInstance<{ instance: InstanceResponse; replicas: number; message: string }>({
|
||||
url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/scale`,
|
||||
method: "POST",
|
||||
data: body,
|
||||
@ -252,7 +255,7 @@ export const getInstanceValuesDiff = (
|
||||
clusterId: string,
|
||||
instanceId: string,
|
||||
) => {
|
||||
return customAxiosInstance<{ current: Record<string, any>; defaults: Record<string, any> }>({
|
||||
return customAxiosInstance<{ current: Record<string, unknown>; defaults: Record<string, unknown> }>({
|
||||
url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/values-diff`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@ -71,11 +71,66 @@ import type { ClusterMonitoring, ClusterMonitoringStatus, NodeMetricsResponse }
|
||||
|
||||
export type NodeMetrics = NodeMetricsResponse;
|
||||
|
||||
export interface UserResourceUsage {
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
username?: string;
|
||||
namespace?: string;
|
||||
cpuUsed?: string;
|
||||
usedCpu?: string;
|
||||
cpuRequest?: string;
|
||||
cpuLimit?: string;
|
||||
memoryUsed?: string;
|
||||
usedMemory?: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit?: string;
|
||||
gpuUsed?: number;
|
||||
usedGpu?: number;
|
||||
gpuAllocated?: number;
|
||||
gpuAllocation?: number;
|
||||
gpuMemoryUsed?: string | number;
|
||||
usedGpuMemory?: string | number;
|
||||
gpuMemUsed?: string | number;
|
||||
gpuMemoryAllocated?: string | number;
|
||||
gpuMemAllocated?: string | number;
|
||||
podCount?: number;
|
||||
instanceCount?: number;
|
||||
cpuRequests?: string;
|
||||
cpuLimits?: string;
|
||||
memoryRequests?: string;
|
||||
memoryLimits?: string;
|
||||
gpuRequests?: number;
|
||||
gpuLimits?: number;
|
||||
gpuMemoryRequestsMb?: number;
|
||||
gpuMemoryLimitsMb?: number;
|
||||
}
|
||||
|
||||
export interface ClusterMetrics extends ClusterMonitoring {
|
||||
/** Internal UI identifier (legacy) */
|
||||
id?: string;
|
||||
nodes?: NodeMetrics[];
|
||||
status?: ClusterMonitoringStatus | 'warning' | 'error';
|
||||
allocatedGpu?: number;
|
||||
allocatedGpuMemoryMb?: number;
|
||||
allocatedGpuMemoryMB?: number;
|
||||
gpuMemoryRequestsMb?: number;
|
||||
gpuMemoryLimitsMb?: number;
|
||||
gpuAllocated?: number;
|
||||
gpuAllocation?: number;
|
||||
cpuRequests?: string;
|
||||
cpuLimits?: string;
|
||||
memoryRequests?: string;
|
||||
memoryLimits?: string;
|
||||
totalGpuMemory?: string | number;
|
||||
usedGpuMemory?: string | number;
|
||||
gpuMemoryUsed?: string | number;
|
||||
totalGpuMem?: string | number;
|
||||
usedGpuMem?: string | number;
|
||||
userResources?: UserResourceUsage[];
|
||||
resourceUsageByUser?: UserResourceUsage[];
|
||||
userResourceUsage?: UserResourceUsage[];
|
||||
resourcesByUser?: UserResourceUsage[];
|
||||
userResourceRows?: UserResourceUsage[];
|
||||
}
|
||||
|
||||
// ==================== Common Types ====================
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box, Settings, StopCircle, CheckCircle, XCircle, Clock,
|
||||
Network, Activity, GitBranch, Layers,
|
||||
Network, Activity, GitBranch, Layers, User,
|
||||
AlertTriangle, HelpCircle, Minus, Plus, Loader2,
|
||||
} from "lucide-react";
|
||||
import type { InstanceResponse } from "@/api";
|
||||
@ -116,6 +116,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
const version = instance.version || "—";
|
||||
const namespace = instance.namespace || "default";
|
||||
const revision = instance.revision ?? "—";
|
||||
const ownerLabel = ownerDisplayName(instance.ownerUsername, instance.ownerId);
|
||||
|
||||
const currentReplicas: number = instance.replicas ?? 0;
|
||||
|
||||
@ -150,7 +151,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hover-lift group relative bg-white border border-slate-200 rounded-lg flex items-center gap-3 px-4 py-3 transition-all">
|
||||
<div className="hover-lift group relative bg-white border border-slate-200 rounded-lg flex flex-col gap-3 px-4 py-3 transition-all lg:flex-row lg:items-center">
|
||||
{/* Left color bar (status) */}
|
||||
<div className={`self-stretch w-1 rounded-full flex-shrink-0 ${statusInfo.bg} border ${statusInfo.border}`} />
|
||||
|
||||
@ -161,10 +162,10 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Name + Chart info */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-4">
|
||||
<div className="w-full min-w-0 flex-1 flex items-center gap-4 lg:w-auto">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold text-slate-900 truncate">{instanceName}</h4>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 mt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Box className="w-3 h-3" />
|
||||
<span className="truncate max-w-[200px]">{chart}:{version}</span>
|
||||
@ -177,6 +178,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
<GitBranch className="w-3 h-3" />
|
||||
rev{revision}
|
||||
</span>
|
||||
{ownerLabel && (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span className="truncate max-w-[120px]">{ownerLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -196,7 +203,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
)}
|
||||
|
||||
{/* Scale controls */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<div className="flex w-full items-center justify-end gap-0.5 flex-shrink-0 lg:w-auto">
|
||||
{canScale ? (
|
||||
<>
|
||||
<button
|
||||
@ -229,7 +236,7 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-1.5 flex-shrink-0 lg:w-auto">
|
||||
<button
|
||||
onClick={() => onViewEntries(instance)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-blue-50 transition-colors text-xs font-medium"
|
||||
@ -266,3 +273,12 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ownerDisplayName = (ownerUsername?: string, ownerId?: string): string => {
|
||||
const username = ownerUsername?.trim();
|
||||
if (username) return username;
|
||||
const id = ownerId?.trim();
|
||||
if (!id) return "";
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
@ -35,7 +35,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
const [valuesYaml, setValuesYaml] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [originalValuesYaml, setOriginalValuesYaml] = useState("");
|
||||
const [modifiedKeys, setModifiedKeys] = useState<string[]>([]);
|
||||
|
||||
// Values Diff support
|
||||
@ -60,7 +59,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
if (data?.current && Object.keys(data.current).length > 0) {
|
||||
const currentYaml = stringifyYaml(data.current, { lineWidth: 0 });
|
||||
setValuesYaml(currentYaml);
|
||||
setOriginalValuesYaml(currentYaml);
|
||||
setDiffData({ current: data.current, defaults: data.defaults ?? {} });
|
||||
}
|
||||
} catch (err) {
|
||||
@ -71,7 +69,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
if (detail.values && Object.keys(detail.values).length > 0) {
|
||||
const y = stringifyYaml(detail.values, { lineWidth: 0 });
|
||||
setValuesYaml(y);
|
||||
setOriginalValuesYaml(y);
|
||||
}
|
||||
} catch (err2) {
|
||||
console.error('[ModifyModal] Failed to load instance detail:', err2);
|
||||
@ -143,29 +140,18 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const computeDeltaValues = (): Record<string, any> | undefined => {
|
||||
if (!valuesYaml.trim()) return undefined;
|
||||
try {
|
||||
const current = parseYaml(valuesYaml);
|
||||
if (!originalValuesYaml) return current; // no original to compare against
|
||||
const original = parseYaml(originalValuesYaml);
|
||||
return diffObjects(current, original);
|
||||
} catch {
|
||||
return parseValuesYaml(valuesYaml);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const deltaValues = computeDeltaValues();
|
||||
if (valuesYaml.trim()) {
|
||||
parseValuesYaml(valuesYaml);
|
||||
}
|
||||
const payload: UpdateInstanceRequest = {
|
||||
version: tag && tag !== instance.version ? tag : undefined,
|
||||
description: description.trim() || undefined,
|
||||
values: deltaValues,
|
||||
valuesYaml: valuesYaml.trim() || undefined,
|
||||
};
|
||||
|
||||
@ -268,7 +254,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
Configuration Values
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
Editing current deployed values. Only modified keys are sent to the server.
|
||||
Editing current deployed values. The full YAML is submitted so nested chart values stay intact.
|
||||
</p>
|
||||
{modifiedKeys.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
@ -368,27 +354,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/** Deep diff: returns only keys in `current` whose values differ from `original` */
|
||||
const diffObjects = (current: Record<string, any>, original: Record<string, any>): Record<string, any> => {
|
||||
const delta: Record<string, any> = {};
|
||||
for (const key of Object.keys(current)) {
|
||||
const cv = current[key];
|
||||
const ov = original[key];
|
||||
if (JSON.stringify(cv) !== JSON.stringify(ov)) {
|
||||
if (typeof cv === 'object' && cv !== null && !Array.isArray(cv) &&
|
||||
typeof ov === 'object' && ov !== null && !Array.isArray(ov)) {
|
||||
const nested = diffObjects(cv, ov);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
delta[key] = nested;
|
||||
}
|
||||
} else {
|
||||
delta[key] = cv;
|
||||
}
|
||||
}
|
||||
}
|
||||
return delta;
|
||||
};
|
||||
|
||||
const parseValuesYaml = (source: string): Record<string, any> => {
|
||||
const parsed = parseYaml(source);
|
||||
if (parsed == null) {
|
||||
|
||||
@ -222,7 +222,7 @@ export const LaunchModal: React.FC<LaunchModalProps> = ({
|
||||
valuesObj = pruneEmptyValues(valuesForm);
|
||||
} else if (inputMethod === "yaml" && valuesYaml.trim()) {
|
||||
try {
|
||||
valuesObj = parseValuesYaml(valuesYaml);
|
||||
parseValuesYaml(valuesYaml);
|
||||
normalizedValuesYaml = valuesYaml.trim();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Gauge, KeyRound, Pencil, RefreshCw, Shield, Trash2, UserPlus, Users, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Gauge, KeyRound, Pencil, RefreshCw, Shield, Trash2, UserPlus, Users, X, type LucideIcon } from "lucide-react";
|
||||
import { createUser, listClusters, listUsers, updateUser, deleteUser, type ClusterResponse, type UserResponse } from "@/api";
|
||||
import { useToast } from "@/shared";
|
||||
import { Button, Input, Badge, LoadingState } from "@/shared/components";
|
||||
import { formatApiError } from "@/shared/utils";
|
||||
import { useAuth } from "@/app/providers";
|
||||
|
||||
type LimitsEditorMode = "edit" | "downgrade";
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { success, error: toastError } = useToast();
|
||||
@ -18,8 +20,8 @@ const UserManagementPage: React.FC = () => {
|
||||
const [role, setRole] = useState("user");
|
||||
const [namespace, setNamespace] = useState("");
|
||||
const [defaultClusterId, setDefaultClusterId] = useState("");
|
||||
const [quotaCpu, setQuotaCpu] = useState("4");
|
||||
const [quotaMemory, setQuotaMemory] = useState("16Gi");
|
||||
const [quotaCpu, setQuotaCpu] = useState("");
|
||||
const [quotaMemory, setQuotaMemory] = useState("");
|
||||
const [quotaGpu, setQuotaGpu] = useState("0");
|
||||
const [quotaGpuMemory, setQuotaGpuMemory] = useState("0");
|
||||
const [mustChangePassword, setMustChangePassword] = useState(true);
|
||||
@ -31,13 +33,14 @@ const UserManagementPage: React.FC = () => {
|
||||
const [editQuotaGpu, setEditQuotaGpu] = useState("");
|
||||
const [editQuotaGpuMemory, setEditQuotaGpuMemory] = useState("");
|
||||
const [savingLimits, setSavingLimits] = useState(false);
|
||||
const [limitsEditorMode, setLimitsEditorMode] = useState<LimitsEditorMode>("edit");
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => (a.username ?? "").localeCompare(b.username ?? "")),
|
||||
[users]
|
||||
);
|
||||
|
||||
const loadUsers = async () => {
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUsers(await listUsers());
|
||||
@ -46,9 +49,9 @@ const UserManagementPage: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [toastError]);
|
||||
|
||||
const loadClusters = async () => {
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const data = await listClusters();
|
||||
const available = data.filter((cluster) => typeof cluster.id === "string" && cluster.id.length > 0);
|
||||
@ -57,12 +60,12 @@ const UserManagementPage: React.FC = () => {
|
||||
} catch (err) {
|
||||
toastError(formatApiError(err) || "Failed to load clusters");
|
||||
}
|
||||
};
|
||||
}, [toastError]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
void loadClusters();
|
||||
}, []);
|
||||
}, [loadClusters, loadUsers]);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@ -96,8 +99,8 @@ const UserManagementPage: React.FC = () => {
|
||||
setRole("user");
|
||||
setNamespace("");
|
||||
setDefaultClusterId(clusters[0]?.id || "");
|
||||
setQuotaCpu("4");
|
||||
setQuotaMemory("16Gi");
|
||||
setQuotaCpu("");
|
||||
setQuotaMemory("");
|
||||
setQuotaGpu("0");
|
||||
setQuotaGpuMemory("0");
|
||||
setMustChangePassword(true);
|
||||
@ -135,6 +138,10 @@ const UserManagementPage: React.FC = () => {
|
||||
const toggleRole = async (target: UserResponse) => {
|
||||
if (!target.id) return;
|
||||
const nextRole = target.role === "admin" ? "user" : "admin";
|
||||
if (nextRole === "user") {
|
||||
openLimitsEditor(target, "downgrade");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateUser(target.id, { role: nextRole });
|
||||
success("User role updated");
|
||||
@ -156,12 +163,13 @@ const UserManagementPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openLimitsEditor = (target: UserResponse) => {
|
||||
const openLimitsEditor = (target: UserResponse, mode: LimitsEditorMode = "edit") => {
|
||||
setLimitsEditorMode(mode);
|
||||
setEditingLimits(target);
|
||||
setEditNamespace(target.namespace || namespaceForUsername(target.username || ""));
|
||||
setEditDefaultClusterId(target.defaultClusterId || clusters[0]?.id || "");
|
||||
setEditQuotaCpu(target.quotaCpu || "4");
|
||||
setEditQuotaMemory(target.quotaMemory || "16Gi");
|
||||
setEditQuotaCpu(target.quotaCpu || "");
|
||||
setEditQuotaMemory(target.quotaMemory || "");
|
||||
setEditQuotaGpu(target.quotaGpu || "0");
|
||||
setEditQuotaGpuMemory(target.quotaGpuMemory || "0");
|
||||
};
|
||||
@ -172,14 +180,15 @@ const UserManagementPage: React.FC = () => {
|
||||
setSavingLimits(true);
|
||||
try {
|
||||
await updateUser(editingLimits.id, {
|
||||
...(limitsEditorMode === "downgrade" ? { role: "user" } : {}),
|
||||
namespace: editNamespace.trim(),
|
||||
defaultClusterId: editDefaultClusterId.trim(),
|
||||
quotaCpu: editQuotaCpu.trim(),
|
||||
quotaMemory: editQuotaMemory.trim(),
|
||||
quotaCpu: quotaForApi(editQuotaCpu, true),
|
||||
quotaMemory: quotaForApi(editQuotaMemory, true),
|
||||
quotaGpu: editQuotaGpu.trim(),
|
||||
quotaGpuMemory: editQuotaGpuMemory.trim(),
|
||||
});
|
||||
success("User limits updated");
|
||||
success(limitsEditorMode === "downgrade" ? "User role and limits updated" : "User limits updated");
|
||||
setEditingLimits(null);
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
@ -190,7 +199,7 @@ const UserManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-screen-2xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-blue-700">
|
||||
@ -207,7 +216,7 @@ const UserManagementPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<form onSubmit={handleCreate} className="rounded-lg border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-blue-600" />
|
||||
@ -281,11 +290,11 @@ const UserManagementPage: React.FC = () => {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={quotaCpu} onChange={(e) => setQuotaCpu(e.target.value)} className="mt-1" placeholder="4" />
|
||||
<Input value={quotaCpu} onChange={(e) => setQuotaCpu(e.target.value)} className="mt-1" placeholder="Unlimited" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={quotaMemory} onChange={(e) => setQuotaMemory(e.target.value)} className="mt-1" placeholder="16Gi" />
|
||||
<Input value={quotaMemory} onChange={(e) => setQuotaMemory(e.target.value)} className="mt-1" placeholder="Unlimited" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
@ -297,7 +306,7 @@ const UserManagementPage: React.FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
CPU and memory use Kubernetes quantities. GPU memory is an integer MB value, for example 10000.
|
||||
Leave CPU or memory blank for no platform quota. GPU and GPU memory default to 0. GPU memory is integer MB, for example 10000.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -327,90 +336,87 @@ const UserManagementPage: React.FC = () => {
|
||||
{loading ? (
|
||||
<LoadingState message="Loading users..." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3">User</th>
|
||||
<th className="px-5 py-3">Role</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Namespace</th>
|
||||
<th className="px-5 py-3">Quota</th>
|
||||
<th className="sticky right-0 z-10 bg-slate-50 px-5 py-3 text-right shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sortedUsers.map((target) => (
|
||||
<tr key={target.id} className="group hover:bg-slate-50">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-slate-900">{target.username}</div>
|
||||
<div className="text-xs text-slate-500">{target.email}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="divide-y divide-slate-100">
|
||||
{sortedUsers.map((target) => (
|
||||
<article key={target.id} className="min-w-0 overflow-hidden px-4 py-4 hover:bg-slate-50 sm:px-5">
|
||||
<div className="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-slate-950">{target.username}</h3>
|
||||
<Badge variant={target.role === "admin" ? "info" : "secondary"} size="sm">
|
||||
{target.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={target.isActive ? "success" : "warning"} size="sm">
|
||||
{target.isActive ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-mono text-xs text-slate-700">{target.namespace || "-"}</div>
|
||||
<div className="text-xs text-slate-500">{target.workspaceName || target.workspaceId}</div>
|
||||
{target.defaultClusterId && (
|
||||
<div className="mt-1 text-xs text-blue-700">{clusterName(clusters, target.defaultClusterId)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-slate-600">
|
||||
{target.role === "admin" ? (
|
||||
<span className="text-slate-400">default workspace</span>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<span>CPU {target.quotaCpu || "-"}</span>
|
||||
<span>Mem {target.quotaMemory || "-"}</span>
|
||||
<span>GPU {target.quotaGpu || "0"} / Mem {target.quotaGpuMemory || "0"}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="sticky right-0 bg-white px-5 py-3 shadow-[-12px_0_18px_-18px_rgba(15,23,42,0.35)] group-hover:bg-slate-50">
|
||||
<div className="grid w-[260px] grid-cols-2 gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => toggleRole(target)}>
|
||||
Make {target.role === "admin" ? "User" : "Admin"}
|
||||
</Button>
|
||||
{target.role !== "admin" && (
|
||||
<Button type="button" variant="secondary" size="sm" icon={Pencil} onClick={() => openLimitsEditor(target)}>
|
||||
Limits
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => toggleActive(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
{target.isActive ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-500">{target.email || target.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full min-w-0 grid-cols-2 gap-2 sm:grid-cols-4 xl:w-auto xl:min-w-[360px] xl:max-w-[480px]">
|
||||
<ActionButton onClick={() => toggleRole(target)} title={`Make ${target.role === "admin" ? "User" : "Admin"}`}>
|
||||
{target.role === "admin" ? "To User" : "To Admin"}
|
||||
</ActionButton>
|
||||
{target.role !== "admin" && (
|
||||
<ActionButton variant="secondary" icon={Pencil} onClick={() => openLimitsEditor(target)}>
|
||||
Limits
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => toggleActive(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
{target.isActive ? "Disable" : "Enable"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
icon={Trash2}
|
||||
onClick={() => handleDelete(target)}
|
||||
disabled={target.id === user?.userId}
|
||||
>
|
||||
Delete
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid min-w-0 gap-4 lg:grid-cols-[minmax(240px,0.85fr)_minmax(0,1.15fr)]">
|
||||
<div className="min-w-0 rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">Namespace</span>
|
||||
<span className="min-w-0 truncate font-mono text-slate-800">{target.namespace || "-"}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">Workspace</span>
|
||||
<span className="min-w-0 truncate text-slate-700">{target.workspaceName || target.workspaceId || "-"}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="shrink-0 text-slate-500">Cluster</span>
|
||||
<span className="min-w-0 truncate text-blue-700">
|
||||
{target.defaultClusterId ? clusterName(clusters, target.defaultClusterId) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{target.role === "admin" ? (
|
||||
<div className="inline-flex rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-500">
|
||||
default workspace
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{quotaChip("CPU", target.quotaCpu || "Unlimited")}
|
||||
{quotaChip("Memory", target.quotaMemory || "Unlimited")}
|
||||
{quotaChip("GPU", target.quotaGpu || "0")}
|
||||
{quotaChip("GPU Mem", target.quotaGpuMemory || "0")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@ -422,10 +428,14 @@ const UserManagementPage: React.FC = () => {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Tenant limits
|
||||
{limitsEditorMode === "downgrade" ? "Convert admin to user" : "Tenant limits"}
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-slate-950">{editingLimits.username}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Changes are applied to workspace metadata and the next tenant binding/deploy refreshes Kubernetes ResourceQuota.</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{limitsEditorMode === "downgrade"
|
||||
? "Set the tenant namespace, default cluster, and resource quota before applying the user role."
|
||||
: "Changes are applied to workspace metadata and the next tenant binding/deploy refreshes Kubernetes ResourceQuota."}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingLimits(null)} className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900">
|
||||
<X className="h-5 w-5" />
|
||||
@ -454,11 +464,11 @@ const UserManagementPage: React.FC = () => {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
CPU
|
||||
<Input value={editQuotaCpu} onChange={(e) => setEditQuotaCpu(e.target.value)} className="mt-1" required />
|
||||
<Input value={editQuotaCpu} onChange={(e) => setEditQuotaCpu(e.target.value)} className="mt-1" placeholder="Unlimited" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Memory
|
||||
<Input value={editQuotaMemory} onChange={(e) => setEditQuotaMemory(e.target.value)} className="mt-1" required />
|
||||
<Input value={editQuotaMemory} onChange={(e) => setEditQuotaMemory(e.target.value)} className="mt-1" placeholder="Unlimited" />
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
GPU
|
||||
@ -475,7 +485,7 @@ const UserManagementPage: React.FC = () => {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={savingLimits}>
|
||||
Save Limits
|
||||
{limitsEditorMode === "downgrade" ? "Save Role and Limits" : "Save Limits"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -499,4 +509,41 @@ const clusterName = (clusters: ClusterResponse[], clusterId: string): string =>
|
||||
return cluster?.name || clusterId;
|
||||
};
|
||||
|
||||
const quotaChip = (label: string, value: React.ReactNode): React.ReactElement => (
|
||||
<div className="min-w-0 rounded-md border border-slate-200 bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-sm text-slate-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActionButton = ({
|
||||
children,
|
||||
icon,
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
variant?: "secondary" | "danger" | "ghost";
|
||||
}): React.ReactElement => (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
icon={icon}
|
||||
className="min-w-0 max-w-full px-2 text-xs leading-tight [&>svg]:shrink-0"
|
||||
{...props}
|
||||
>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const quotaForApi = (value: string, blankMeansUnlimited = false): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed && blankMeansUnlimited) {
|
||||
return "unlimited";
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export default UserManagementPage;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* 显示单个集群的监控信息
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Activity, CheckCircle, AlertTriangle, XCircle, HelpCircle, Clock, Cpu, Database, Server as ServerIcon, ChevronDown, ChevronUp, TrendingUp } from "lucide-react";
|
||||
import { Activity, CheckCircle, AlertTriangle, XCircle, HelpCircle, Clock, Cpu, Database, Server as ServerIcon, ChevronDown, ChevronUp, TrendingUp, Users } from "lucide-react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import type { ClusterMetrics } from "@/core/types";
|
||||
import { NodeMetricCard } from "./NodeMetricCard";
|
||||
@ -20,6 +20,9 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
const podCount = cluster.podCount ?? 0;
|
||||
const totalGpu = cluster.totalGpu ?? 0;
|
||||
const usedGpu = cluster.usedGpu ?? 0;
|
||||
const allocatedGpu = firstNumber(cluster.gpuAllocated, cluster.allocatedGpu, cluster.gpuAllocation, usedGpu);
|
||||
const usedGpuMemory = firstDisplayValue(cluster.allocatedGpuMemoryMb, cluster.allocatedGpuMemoryMB, cluster.gpuMemoryRequestsMb, cluster.usedGpuMemory, cluster.gpuMemoryUsed, cluster.usedGpuMem);
|
||||
const totalGpuMemory = firstDisplayValue(cluster.totalGpuMemory, cluster.totalGpuMem);
|
||||
const cpuUsage = cluster.cpuUsage ?? 0;
|
||||
const memoryUsage = cluster.memoryUsage ?? 0;
|
||||
const gpuUsage = cluster.gpuUsage ?? 0;
|
||||
@ -27,7 +30,11 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
const totalCpu = cluster.totalCpu ?? "N/A";
|
||||
const usedMemory = cluster.usedMemory ?? "N/A";
|
||||
const totalMemory = cluster.totalMemory ?? "N/A";
|
||||
const cpuRequestText = firstDisplayValue(cluster.cpuRequests, usedCpu);
|
||||
const memoryRequestText = firstDisplayValue(cluster.memoryRequests, usedMemory);
|
||||
const hasClusterTotals = Boolean(cluster.totalCpu || cluster.totalMemory || cluster.nodeCount);
|
||||
const lastCheckedText = cluster.lastCheck ? new Date(cluster.lastCheck).toLocaleString() : "N/A";
|
||||
const userResourceRows = getUserResourceRows(cluster);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (status) {
|
||||
@ -76,13 +83,13 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-3">
|
||||
<div className="grid grid-cols-2 gap-4 mb-3 md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Uptime</p>
|
||||
<p className="text-sm text-slate-700 font-mono mt-1">{uptime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Nodes</p>
|
||||
<p className="text-xs text-slate-500">{hasClusterTotals ? "Nodes" : "Visible Nodes"}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<ServerIcon className="w-3 h-3 text-blue-400" />
|
||||
<p className="text-sm text-slate-700 font-mono">{nodeCount}</p>
|
||||
@ -95,7 +102,13 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">GPU</p>
|
||||
<p className="text-sm text-slate-700 font-mono mt-1">
|
||||
{usedGpu}/{totalGpu || "N/A"}
|
||||
{hasClusterTotals ? `${usedGpu}/${totalGpu || "N/A"}` : `${allocatedGpu} allocated`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">GPU Mem</p>
|
||||
<p className="text-sm text-slate-700 font-mono mt-1">
|
||||
{usedGpuMemory || "N/A"}{totalGpuMemory ? ` / ${totalGpuMemory}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -105,16 +118,18 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Cpu className="w-3 h-3 text-blue-400" />
|
||||
<p className="text-xs text-slate-500">CPU (Cluster Total)</p>
|
||||
<p className="text-xs text-slate-500">{hasClusterTotals ? "CPU (Cluster Total)" : "CPU Requests"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 font-mono">{usedCpu} / {totalCpu}</p>
|
||||
<p className="text-sm text-slate-700 font-mono">
|
||||
{hasClusterTotals ? `${usedCpu} / ${totalCpu}` : cpuRequestText || "0 cores"}
|
||||
</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(cpuUsage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{cpuUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{hasClusterTotals ? `${cpuUsage.toFixed(1)}%` : "self-scoped allocation"}</p>
|
||||
{cluster.maxNodeCpu && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<div className="flex items-center gap-1">
|
||||
@ -132,16 +147,18 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="w-3 h-3 text-green-400" />
|
||||
<p className="text-xs text-slate-500">Memory (Cluster Total)</p>
|
||||
<p className="text-xs text-slate-500">{hasClusterTotals ? "Memory (Cluster Total)" : "Memory Requests"}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 font-mono">{usedMemory} / {totalMemory}</p>
|
||||
<p className="text-sm text-slate-700 font-mono">
|
||||
{hasClusterTotals ? `${usedMemory} / ${totalMemory}` : memoryRequestText || "0 B"}
|
||||
</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(memoryUsage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{memoryUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{hasClusterTotals ? `${memoryUsage.toFixed(1)}%` : "self-scoped allocation"}</p>
|
||||
{cluster.maxNodeMemory && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<div className="flex items-center gap-1">
|
||||
@ -156,20 +173,20 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalGpu > 0 && (
|
||||
{(totalGpu > 0 || allocatedGpu > 0) && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-3 h-3 text-purple-400" />
|
||||
<p className="text-xs text-slate-500">GPU (Cluster Total)</p>
|
||||
<p className="text-xs text-slate-500">GPU Allocation</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 font-mono">{usedGpu} / {totalGpu}</p>
|
||||
<p className="text-sm text-slate-700 font-mono">{allocatedGpu} / {totalGpu || "N/A"}</p>
|
||||
<div className="mt-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(gpuUsage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{gpuUsage.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{hasClusterTotals ? `${gpuUsage.toFixed(1)}%` : "self-scoped allocation"}</p>
|
||||
{cluster.maxNodeGpu && cluster.maxNodeGpu > 0 && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-200">
|
||||
<div className="flex items-center gap-1">
|
||||
@ -184,8 +201,62 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(usedGpuMemory || totalGpuMemory) && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="w-3 h-3 text-fuchsia-500" />
|
||||
<p className="text-xs text-slate-500">GPU Mem</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 font-mono">
|
||||
{usedGpuMemory || "0"}{totalGpuMemory ? ` / ${totalGpuMemory}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">requests.nvidia.com/gpumem</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userResourceRows.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<Users className="h-4 w-4 text-slate-500" />
|
||||
<h4 className="text-sm font-semibold text-slate-900">User Resources</h4>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[720px] w-full text-left text-xs">
|
||||
<thead className="bg-white text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">User</th>
|
||||
<th className="px-3 py-2 font-medium">Namespace</th>
|
||||
<th className="px-3 py-2 font-medium">CPU</th>
|
||||
<th className="px-3 py-2 font-medium">Memory</th>
|
||||
<th className="px-3 py-2 font-medium">GPU</th>
|
||||
<th className="px-3 py-2 font-medium">GPU Mem</th>
|
||||
<th className="px-3 py-2 font-medium">Pods</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{userResourceRows.map((row, index) => (
|
||||
<tr key={`${row.userId || row.username || row.userName || "user"}-${index}`} className="bg-white">
|
||||
<td className="max-w-[180px] truncate px-3 py-2 font-medium text-slate-800">
|
||||
{row.username || row.userName || shortId(row.userId) || "-"}
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate px-3 py-2 font-mono text-slate-600">{row.namespace || "-"}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{firstDisplayValue(row.cpuRequests, row.usedCpu, row.cpuUsed, row.cpuRequest, row.cpuLimits, row.cpuLimit) || "-"}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{firstDisplayValue(row.memoryRequests, row.usedMemory, row.memoryUsed, row.memoryRequest, row.memoryLimits, row.memoryLimit) || "-"}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{firstNumber(row.gpuRequests, row.gpuAllocated, row.gpuAllocation, row.usedGpu, row.gpuUsed) ?? 0}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">
|
||||
{firstDisplayValue(row.gpuMemoryRequestsMb, row.gpuMemoryAllocated, row.gpuMemAllocated, row.usedGpuMemory, row.gpuMemoryUsed, row.gpuMemUsed) || "0"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{row.podCount ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Last checked: {lastCheckedText}</span>
|
||||
@ -233,3 +304,34 @@ export const ClusterMonitorCard: React.FC<ClusterMonitorCardProps> = ({ cluster
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const firstNumber = (...values: Array<number | undefined | null>): number => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const firstDisplayValue = (...values: Array<string | number | undefined | null>): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getUserResourceRows = (cluster: ClusterMetrics) =>
|
||||
cluster.resourceUsageByUser || cluster.userResources || cluster.userResourceUsage || cluster.resourcesByUser || cluster.userResourceRows || [];
|
||||
|
||||
const shortId = (value?: string): string => {
|
||||
const id = value?.trim();
|
||||
if (!id) return "";
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* 监控集群状态和健康信息
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Activity, Server, RefreshCw } from "lucide-react";
|
||||
import { Activity, Database, Server, RefreshCw } from "lucide-react";
|
||||
import { PageHeader, StatsCard, Button, LoadingState, ErrorState, EmptyState } from "@/shared";
|
||||
import { useToast } from "@/shared";
|
||||
import { ClusterErrors, SuccessMessages, formatApiError } from "@/shared/utils";
|
||||
@ -107,6 +107,12 @@ const MonitoringClustersPage: React.FC = () => {
|
||||
const healthyCount = clusters.filter(c => c.status === "healthy").length;
|
||||
const warningCount = clusters.filter(c => c.status === "warning" || c.status === "unknown").length;
|
||||
const errorCount = clusters.filter(c => c.status === "error" || c.status === "unhealthy").length;
|
||||
const allocatedGpu = clusters.reduce(
|
||||
(sum, cluster) => sum + firstNumber(cluster.gpuAllocated, cluster.allocatedGpu, cluster.gpuAllocation, cluster.usedGpu),
|
||||
0
|
||||
);
|
||||
const totalGpu = clusters.reduce((sum, cluster) => sum + (cluster.totalGpu ?? 0), 0);
|
||||
const gpuMemoryText = summarizeGpuMemory(clusters);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -127,7 +133,7 @@ const MonitoringClustersPage: React.FC = () => {
|
||||
</PageHeader>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-6 gap-4">
|
||||
<StatsCard
|
||||
title="Total Clusters"
|
||||
value={clusters.length}
|
||||
@ -152,6 +158,18 @@ const MonitoringClustersPage: React.FC = () => {
|
||||
icon={Activity}
|
||||
variant="red"
|
||||
/>
|
||||
<StatsCard
|
||||
title="GPU Allocation"
|
||||
value={`${allocatedGpu}/${totalGpu || "N/A"}`}
|
||||
icon={Activity}
|
||||
variant="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="GPU Mem"
|
||||
value={gpuMemoryText}
|
||||
icon={Database}
|
||||
variant="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh Info */}
|
||||
@ -173,3 +191,40 @@ const MonitoringClustersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
export default MonitoringClustersPage;
|
||||
|
||||
const firstNumber = (...values: Array<number | undefined | null>): number => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const summarizeGpuMemory = (clusters: ClusterMetrics[]): string => {
|
||||
const usedValues = clusters
|
||||
.map((cluster) => firstText(cluster.allocatedGpuMemoryMb, cluster.allocatedGpuMemoryMB, cluster.gpuMemoryRequestsMb, cluster.usedGpuMemory, cluster.gpuMemoryUsed, cluster.usedGpuMem))
|
||||
.filter(Boolean);
|
||||
const totalValues = clusters
|
||||
.map((cluster) => firstText(cluster.totalGpuMemory, cluster.totalGpuMem))
|
||||
.filter(Boolean);
|
||||
if (usedValues.length === 0 && totalValues.length === 0) {
|
||||
return "N/A";
|
||||
}
|
||||
if (usedValues.length === 1 && totalValues.length <= 1) {
|
||||
return totalValues[0] ? `${usedValues[0] || "0"} / ${totalValues[0]}` : usedValues[0];
|
||||
}
|
||||
return `${usedValues.length || 0} clusters`;
|
||||
};
|
||||
|
||||
const firstText = (...values: Array<string | number | undefined | null>): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@ -21,7 +21,7 @@ export default function SidebarLayout({ items, children }: SidebarLayoutProps) {
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col flex-1">
|
||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col">
|
||||
{React.Children.map(children, (child) => {
|
||||
// 将 toggleSidebar 函数传递给子组件
|
||||
if (React.isValidElement(child)) {
|
||||
|
||||
@ -30,7 +30,7 @@ export default function TopNavLayout({
|
||||
onSignOut={onSignOut}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
<main className="flex-1 w-full max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 py-6 space-y-6">
|
||||
<main className="min-w-0 flex-1 w-full max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-10 py-6 space-y-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -143,6 +143,14 @@ export function formatApiError(error: any): string | null {
|
||||
|
||||
// Layer 2: HTTP Status Codes (标准HTTP错误)
|
||||
const status = error?.response?.status;
|
||||
const apiMessage = error?.response?.data?.message;
|
||||
const apiError = error?.response?.data?.error;
|
||||
if ((status === 400 || status === 403 || status === 422) && apiMessage) {
|
||||
return apiMessage;
|
||||
}
|
||||
if ((status === 400 || status === 403 || status === 422) && apiError) {
|
||||
return apiError;
|
||||
}
|
||||
if (status) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
@ -162,12 +170,12 @@ export function formatApiError(error: any): string | null {
|
||||
|
||||
// Layer 3: API Response Messages (后端返回的具体错误)
|
||||
// 提取后端返回的详细错误信息
|
||||
if (error?.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
if (apiMessage) {
|
||||
return apiMessage;
|
||||
}
|
||||
|
||||
if (error?.response?.data?.error) {
|
||||
return error.response.data.error;
|
||||
if (apiError) {
|
||||
return apiError;
|
||||
}
|
||||
|
||||
// Layer 4: Generic Error Messages
|
||||
@ -243,4 +251,3 @@ export type ErrorMessages =
|
||||
| typeof InstanceErrors
|
||||
| typeof ValidationErrors
|
||||
| typeof BusinessErrors;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user