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:
Ivan087
2026-05-20 16:56:29 +08:00
parent 8f90cf0f0d
commit 33ddaf97db
59 changed files with 4805 additions and 457 deletions

View File

@ -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",
});

View File

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

View File

@ -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)}`;
};

View File

@ -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) {

View File

@ -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);

View File

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

View File

@ -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)}`;
};

View File

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

View File

@ -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)) {

View File

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

View File

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