diff --git a/console-web/README.md b/console-web/README.md index 273a26b..1b4f8ca 100755 --- a/console-web/README.md +++ b/console-web/README.md @@ -50,7 +50,7 @@ Then configure the backend with `CORS_ALLOWED_ORIGINS` (see deploy README). ## Environment variables -| Variable | Description | Default | -|----------|-------------|--------| -| `NEXT_PUBLIC_BASE_PATH` | Base path for the app (e.g. `/console`) | `""` | -| `NEXT_PUBLIC_API_BASE_URL` | API base URL (relative or absolute) | `"/api/v1"` | +| Variable | Description | Default | +| -------------------------- | --------------------------------------- | ----------- | +| `NEXT_PUBLIC_BASE_PATH` | Base path for the app (e.g. `/console`) | `""` | +| `NEXT_PUBLIC_API_BASE_URL` | API base URL (relative or absolute) | `"/api/v1"` | diff --git a/console-web/app/(auth)/auth/login/page.tsx b/console-web/app/(auth)/auth/login/page.tsx index f405905..b8e1b45 100755 --- a/console-web/app/(auth)/auth/login/page.tsx +++ b/console-web/app/(auth)/auth/login/page.tsx @@ -32,7 +32,10 @@ export default function LoginPage() { await login(token.trim()) toast.success(t("Login successful")) } catch (error: unknown) { - const message = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : t("Login failed") + const message = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : t("Login failed") toast.error(message) } finally { setLoading(false) diff --git a/console-web/app/(auth)/layout.tsx b/console-web/app/(auth)/layout.tsx index 2740866..8f56e4a 100755 --- a/console-web/app/(auth)/layout.tsx +++ b/console-web/app/(auth)/layout.tsx @@ -1,11 +1,3 @@ -export default function AuthLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( -
- {children} -
- ) +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return
{children}
} diff --git a/console-web/app/(dashboard)/cluster/page.tsx b/console-web/app/(dashboard)/cluster/page.tsx index bea4b8b..6edc432 100644 --- a/console-web/app/(dashboard)/cluster/page.tsx +++ b/console-web/app/(dashboard)/cluster/page.tsx @@ -7,21 +7,8 @@ import { RiAddLine } from "@remixicon/react" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Spinner } from "@/components/ui/spinner" @@ -96,9 +83,7 @@ export default function ClusterPage() {

{t("Cluster")}

-

- {t("Cluster nodes, capacity and namespaces.")} -

+

{t("Cluster nodes, capacity and namespaces.")}

@@ -259,9 +244,7 @@ export default function ClusterPage() { {ns.name} {ns.status} - {ns.created_at - ? new Date(ns.created_at).toLocaleString() - : "-"} + {ns.created_at ? new Date(ns.created_at).toLocaleString() : "-"} ))} diff --git a/console-web/app/(dashboard)/layout.tsx b/console-web/app/(dashboard)/layout.tsx index c75f9c4..deb59b9 100755 --- a/console-web/app/(dashboard)/layout.tsx +++ b/console-web/app/(dashboard)/layout.tsx @@ -22,12 +22,7 @@ import { routes } from "@/lib/routes" import { cn } from "@/lib/utils" import { LanguageSwitcher } from "@/components/language-switcher" import { ThemeSwitcher } from "@/components/theme-switcher" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" const navItems = [ { href: routes.dashboard, icon: RiDashboardLine, labelKey: "Dashboard" }, @@ -35,11 +30,7 @@ const navItems = [ { href: routes.cluster, icon: RiNodeTree, labelKey: "Cluster" }, ] -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode -}) { +export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { t } = useTranslation() const { logout } = useAuth() const pathname = usePathname() @@ -59,8 +50,7 @@ export default function DashboardLayout({ {navItems.map((item) => { const Icon = item.icon const isActive = - pathname === item.href || - (item.href !== routes.dashboard && pathname.startsWith(item.href)) + pathname === item.href || (item.href !== routes.dashboard && pathname.startsWith(item.href)) return ( @@ -85,8 +75,7 @@ export default function DashboardLayout({ const activeItem = navItems.find( (item) => - pathname === item.href || - (item.href !== routes.dashboard && pathname.startsWith(item.href)), + pathname === item.href || (item.href !== routes.dashboard && pathname.startsWith(item.href)), ) ?? navItems[0] const ActiveIcon = activeItem.icon return ( @@ -114,7 +103,6 @@ export default function DashboardLayout({ )} diff --git a/console-web/app/(dashboard)/page.tsx b/console-web/app/(dashboard)/page.tsx index 329eeb7..8e66cf2 100755 --- a/console-web/app/(dashboard)/page.tsx +++ b/console-web/app/(dashboard)/page.tsx @@ -5,13 +5,7 @@ import Link from "next/link" import { RiServerLine, RiNodeTree } from "@remixicon/react" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { routes } from "@/lib/routes" @@ -37,7 +31,9 @@ export default function DashboardPage() { @@ -48,13 +44,13 @@ export default function DashboardPage() { {t("Cluster")}
- - {t("View cluster nodes, resources and namespaces.")} - + {t("View cluster nodes, resources and namespaces.")} diff --git a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx index bea6101..f97d1f3 100644 --- a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx +++ b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx @@ -5,31 +5,12 @@ import Link from "next/link" import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { - RiArrowLeftLine, - RiDeleteBinLine, - RiAddLine, - RiFileList3Line, - RiRestartLine, -} from "@remixicon/react" +import { RiArrowLeftLine, RiDeleteBinLine, RiAddLine, RiFileList3Line, RiRestartLine } from "@remixicon/react" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Spinner } from "@/components/ui/spinner" @@ -152,7 +133,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) } const handleDeletePool = async (poolName: string) => { - if (!confirm(t("Delete pool \"{{name}}\"?", { name: poolName }))) return + if (!confirm(t('Delete pool "{{name}}"?', { name: poolName }))) return setDeletingPool(poolName) try { await api.deletePool(namespace, name, poolName) @@ -181,7 +162,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) } const handleDeletePod = async (podName: string) => { - if (!confirm(t("Delete pod \"{{name}}\"?", { name: podName }))) return + if (!confirm(t('Delete pod "{{name}}"?', { name: podName }))) return setDeletingPod(podName) try { await api.deletePod(namespace, name, podName) @@ -264,12 +245,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {t("Back")} - @@ -279,7 +255,9 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps)

{tenant.name} / {tenant.namespace}

-

{t("State")}: {tenant.state}

+

+ {t("State")}: {tenant.state} +

@@ -306,9 +284,14 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {t("Details")} -

{t("Image")}: {tenant.image || "-"}

-

{t("Mount Path")}: {tenant.mount_path || "-"}

-

{t("Created")}:{" "} +

+ {t("Image")}: {tenant.image || "-"} +

+

+ {t("Mount Path")}: {tenant.mount_path || "-"} +

+

+ {t("Created")}:{" "} {tenant.created_at ? new Date(tenant.created_at).toLocaleString() : "-"}

@@ -335,9 +318,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {svc.name} {svc.service_type} - - {svc.ports.map((p) => `${p.name}:${p.port}`).join(", ")} - + {svc.ports.map((p) => `${p.name}:${p.port}`).join(", ")} ))} @@ -397,7 +378,9 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) - {t("All pools in this tenant form one unified cluster. Data is distributed across all pools (erasure-coded); every pool is in use. To see disk usage per pool, use RustFS Console (S3 API port 9001) or check PVC usage in the cluster (e.g. kubectl).")} + {t( + "All pools in this tenant form one unified cluster. Data is distributed across all pools (erasure-coded); every pool is in use. To see disk usage per pool, use RustFS Console (S3 API port 9001) or check PVC usage in the cluster (e.g. kubectl).", + )} @@ -429,9 +412,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) type="number" min={1} value={addPoolForm.servers} - onChange={(e) => - setAddPoolForm((f) => ({ ...f, servers: parseInt(e.target.value, 10) || 0 })) - } + onChange={(e) => setAddPoolForm((f) => ({ ...f, servers: parseInt(e.target.value, 10) || 0 }))} />
@@ -495,7 +476,9 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {p.servers} {p.volumes_per_server} {p.state} - {p.ready_replicas}/{p.replicas} + + {p.ready_replicas}/{p.replicas} + @@ -539,12 +526,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {p.age}
- @@ -629,9 +613,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {ev.reason} {ev.message} {ev.involved_object} - - {ev.last_timestamp || "-"} - + {ev.last_timestamp || "-"} )) )} diff --git a/console-web/app/(dashboard)/tenants/new/page.tsx b/console-web/app/(dashboard)/tenants/new/page.tsx index dca0c5a..05f0b57 100644 --- a/console-web/app/(dashboard)/tenants/new/page.tsx +++ b/console-web/app/(dashboard)/tenants/new/page.tsx @@ -37,9 +37,7 @@ export default function TenantCreatePage() { const [loading, setLoading] = useState(false) const updatePool = (index: number, field: keyof CreatePoolRequest, value: string | number) => { - setPools((prev) => - prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)) - ) + setPools((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))) } const addPool = () => { @@ -119,12 +117,7 @@ export default function TenantCreatePage() {
- setName(e.target.value)} - placeholder="my-tenant" - /> + setName(e.target.value)} placeholder="my-tenant" />
@@ -137,7 +130,9 @@ export default function TenantCreatePage() {
- +
- + {pools.map((pool, index) => ( -
+
- {t("Pool")} {index + 1} + + {t("Pool")} {index + 1} + {pools.length > 1 && (
@@ -213,9 +207,7 @@ export default function TenantCreatePage() { type="number" min={1} value={pool.volumes_per_server} - onChange={(e) => - updatePool(index, "volumes_per_server", parseInt(e.target.value, 10) || 0) - } + onChange={(e) => updatePool(index, "volumes_per_server", parseInt(e.target.value, 10) || 0)} />
@@ -227,7 +219,9 @@ export default function TenantCreatePage() { />
- + updatePool(index, "storage_class", e.target.value)} @@ -246,7 +240,9 @@ export default function TenantCreatePage() { {loading ? t("Creating...") : t("Create Tenant")}
diff --git a/console-web/app/(dashboard)/tenants/page.tsx b/console-web/app/(dashboard)/tenants/page.tsx index 33dab67..81197c0 100644 --- a/console-web/app/(dashboard)/tenants/page.tsx +++ b/console-web/app/(dashboard)/tenants/page.tsx @@ -8,14 +8,7 @@ import { RiAddLine, RiEyeLine, RiDeleteBinLine } from "@remixicon/react" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Spinner } from "@/components/ui/spinner" import { routes } from "@/lib/routes" import * as api from "@/lib/api" @@ -46,7 +39,7 @@ export default function TenantsListPage() { }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount const handleDelete = async (namespace: string, name: string) => { - if (!confirm(t("Delete tenant \"{{name}}\"? This cannot be undone.", { name }))) return + if (!confirm(t('Delete tenant "{{name}}"? This cannot be undone.', { name }))) return setDeleting(`${namespace}/${name}`) try { await api.deleteTenant(namespace, name) @@ -84,7 +77,9 @@ export default function TenantsListPage() { {t("No tenants yet. Create one to get started.")}
@@ -115,15 +110,9 @@ export default function TenantsListPage() { {tnt.state} - - {tnt.pools.length === 0 - ? "-" - : tnt.pools.map((p) => p.name).join(", ")} - + {tnt.pools.length === 0 ? "-" : tnt.pools.map((p) => p.name).join(", ")} - {tnt.created_at - ? new Date(tnt.created_at).toLocaleString() - : "-"} + {tnt.created_at ? new Date(tnt.created_at).toLocaleString() : "-"}
diff --git a/console-web/components/page-header.tsx b/console-web/components/page-header.tsx index dd02071..5532897 100755 --- a/console-web/components/page-header.tsx +++ b/console-web/components/page-header.tsx @@ -14,12 +14,7 @@ export function PageHeader({ sticky?: boolean }) { return ( -
+
{children} {description} diff --git a/console-web/components/ui/dropdown-menu.tsx b/console-web/components/ui/dropdown-menu.tsx index 4273f6e..1b00ba6 100644 --- a/console-web/components/ui/dropdown-menu.tsx +++ b/console-web/components/ui/dropdown-menu.tsx @@ -81,7 +81,10 @@ function DropdownMenuCheckboxItem({ )} {...props} > - + @@ -109,7 +112,10 @@ function DropdownMenuRadioItem({ )} {...props} > - + @@ -138,13 +144,21 @@ function DropdownMenuLabel({ function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { return ( - + ) } function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { return ( - + ) } @@ -174,7 +188,10 @@ function DropdownMenuSubTrigger({ ) } -function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { return ( `/namespaces/${encodeURIComponent(namespace)}` -const tenant = (namespace: string, name: string) => - `${ns(namespace)}/tenants/${encodeURIComponent(name)}` +const tenant = (namespace: string, name: string) => `${ns(namespace)}/tenants/${encodeURIComponent(name)}` const pools = (namespace: string, name: string) => `${tenant(namespace, name)}/pools` const pool = (namespace: string, name: string, poolName: string) => `${pools(namespace, name)}/${encodeURIComponent(poolName)}` @@ -36,29 +35,22 @@ export async function listTenants(): Promise { return apiClient.get("/tenants") } -export async function listTenantsByNamespace( - namespace: string -): Promise { +export async function listTenantsByNamespace(namespace: string): Promise { return apiClient.get(`${ns(namespace)}/tenants`) } -export async function getTenant( - namespace: string, - name: string -): Promise { +export async function getTenant(namespace: string, name: string): Promise { return apiClient.get(`${tenant(namespace, name)}`) } -export async function createTenant( - body: CreateTenantRequest -): Promise { +export async function createTenant(body: CreateTenantRequest): Promise { return apiClient.post("/tenants", body) } export async function updateTenant( namespace: string, name: string, - body: UpdateTenantRequest + body: UpdateTenantRequest, ): Promise<{ success: boolean; message: string; tenant: TenantListItem }> { const payload: Record = {} if (body.image !== undefined) payload.image = body.image @@ -71,70 +63,41 @@ export async function updateTenant( return apiClient.put(`${tenant(namespace, name)}`, Object.keys(payload).length ? payload : undefined) } -export async function deleteTenant( - namespace: string, - name: string -): Promise<{ success: boolean; message: string }> { +export async function deleteTenant(namespace: string, name: string): Promise<{ success: boolean; message: string }> { return apiClient.delete(`${tenant(namespace, name)}`) } // ----- Pools ----- -export async function listPools( - namespace: string, - tenantName: string -): Promise { +export async function listPools(namespace: string, tenantName: string): Promise { return apiClient.get(`${pools(namespace, tenantName)}`) } -export async function addPool( - namespace: string, - tenantName: string, - body: AddPoolRequest -): Promise { +export async function addPool(namespace: string, tenantName: string, body: AddPoolRequest): Promise { return apiClient.post(`${pools(namespace, tenantName)}`, body) } -export async function deletePool( - namespace: string, - tenantName: string, - poolName: string -): Promise { - return apiClient.delete( - `${pool(namespace, tenantName, poolName)}` - ) +export async function deletePool(namespace: string, tenantName: string, poolName: string): Promise { + return apiClient.delete(`${pool(namespace, tenantName, poolName)}`) } // ----- Pods ----- -export async function listPods( - namespace: string, - tenantName: string -): Promise { +export async function listPods(namespace: string, tenantName: string): Promise { return apiClient.get(`${pods(namespace, tenantName)}`) } -export async function getPod( - namespace: string, - tenantName: string, - podName: string -): Promise { +export async function getPod(namespace: string, tenantName: string, podName: string): Promise { return apiClient.get(`${pod(namespace, tenantName, podName)}`) } -export async function deletePod( - namespace: string, - tenantName: string, - podName: string -): Promise { - return apiClient.delete( - `${pod(namespace, tenantName, podName)}` - ) +export async function deletePod(namespace: string, tenantName: string, podName: string): Promise { + return apiClient.delete(`${pod(namespace, tenantName, podName)}`) } export async function restartPod( namespace: string, tenantName: string, podName: string, - force = false + force = false, ): Promise<{ success: boolean; message: string }> { return apiClient.post(`${pod(namespace, tenantName, podName)}/restart`, { force, @@ -145,23 +108,18 @@ export async function getPodLogs( namespace: string, tenantName: string, podName: string, - params?: { container?: string; tail_lines?: number; timestamps?: boolean } + params?: { container?: string; tail_lines?: number; timestamps?: boolean }, ): Promise { const search = new URLSearchParams() if (params?.container) search.set("container", params.container) if (params?.tail_lines != null) search.set("tail_lines", String(params.tail_lines)) if (params?.timestamps) search.set("timestamps", "true") const q = search.toString() - return apiClient.getText( - `${pod(namespace, tenantName, podName)}/logs${q ? `?${q}` : ""}` - ) + return apiClient.getText(`${pod(namespace, tenantName, podName)}/logs${q ? `?${q}` : ""}`) } // ----- Events ----- -export async function listTenantEvents( - namespace: string, - tenantName: string -): Promise { +export async function listTenantEvents(namespace: string, tenantName: string): Promise { return apiClient.get(events(namespace, tenantName)) } diff --git a/console-web/tsconfig.json b/console-web/tsconfig.json index 3a13f90..cc9ed39 100755 --- a/console-web/tsconfig.json +++ b/console-web/tsconfig.json @@ -22,13 +22,6 @@ "@/*": ["./*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], "exclude": ["node_modules"] } diff --git a/console-web/types/api.ts b/console-web/types/api.ts index a403756..b2d6511 100644 --- a/console-web/types/api.ts +++ b/console-web/types/api.ts @@ -181,10 +181,7 @@ export interface ContainerStateTerminated { finished_at?: string } -export type ContainerState = - | ContainerStateRunning - | ContainerStateWaiting - | ContainerStateTerminated +export type ContainerState = ContainerStateRunning | ContainerStateWaiting | ContainerStateTerminated export interface ContainerInfo { name: string diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs index 2454368..4cd4449 100755 --- a/src/console/handlers/tenants.rs +++ b/src/console/handlers/tenants.rs @@ -473,6 +473,113 @@ pub async fn update_tenant( })) } +/// 获取 Tenant YAML +pub async fn get_tenant_yaml( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, +) -> Result> { + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + let mut tenant = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + // Remove managed fields to keep YAML readable (same as MinIO operator) + tenant.metadata.managed_fields = None; + + let yaml_str = serde_yaml_ng::to_string(&tenant).map_err(|e| Error::InternalServer { + message: format!("Failed to serialize Tenant to YAML: {}", e), + })?; + + Ok(Json(TenantYAML { yaml: yaml_str })) +} + +/// 更新 Tenant YAML +pub async fn put_tenant_yaml( + Path((namespace, name)): Path<(String, String)>, + Extension(claims): Extension, + Json(req): Json, +) -> Result> { + let in_tenant: Tenant = serde_yaml_ng::from_str(&req.yaml).map_err(|e| Error::BadRequest { + message: format!("Invalid Tenant YAML: {}", e), + })?; + + // Validate: name and namespace in YAML must match URL params + let in_name = in_tenant.metadata.name.as_deref().unwrap_or_default(); + let in_ns = in_tenant.metadata.namespace.as_deref().unwrap_or_default(); + if !in_name.is_empty() && in_name != name { + return Err(Error::BadRequest { + message: format!( + "Tenant name in YAML '{}' does not match URL '{}'", + in_name, name + ), + }); + } + if !in_ns.is_empty() && in_ns != namespace { + return Err(Error::BadRequest { + message: format!( + "Tenant namespace in YAML '{}' does not match URL '{}'", + in_ns, namespace + ), + }); + } + + // Validate: at least one pool + if in_tenant.spec.pools.is_empty() { + return Err(Error::BadRequest { + message: "Tenant must have at least one pool".to_string(), + }); + } + + // Validate: no duplicate pool names + let mut pool_names = std::collections::HashSet::new(); + for pool in &in_tenant.spec.pools { + if !pool_names.insert(&pool.name) { + return Err(Error::BadRequest { + message: format!("Duplicate pool name '{}'", pool.name), + }); + } + } + + let client = create_client(&claims).await?; + let api: Api = Api::namespaced(client, &namespace); + + // Get the current Tenant (to preserve resourceVersion and safe metadata) + let mut current = api + .get(&name) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + // Only update safe fields: spec, metadata.labels, metadata.annotations, metadata.finalizers + current.spec = in_tenant.spec; + if let Some(labels) = in_tenant.metadata.labels { + current.metadata.labels = Some(labels); + } + if let Some(annotations) = in_tenant.metadata.annotations { + current.metadata.annotations = Some(annotations); + } + if let Some(finalizers) = in_tenant.metadata.finalizers { + current.metadata.finalizers = Some(finalizers); + } + + let updated = api + .replace(&name, &Default::default(), ¤t) + .await + .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?; + + // Return the updated Tenant YAML (clean, without managedFields) + let mut clean = updated; + clean.metadata.managed_fields = None; + + let yaml_str = serde_yaml_ng::to_string(&clean).map_err(|e| Error::InternalServer { + message: format!("Failed to serialize Tenant to YAML: {}", e), + })?; + + Ok(Json(TenantYAML { yaml: yaml_str })) +} + /// 创建 Kubernetes 客户端 async fn create_client(claims: &Claims) -> Result { let mut config = kube::Config::infer() diff --git a/src/console/models/tenant.rs b/src/console/models/tenant.rs index 8bda3f3..6501753 100755 --- a/src/console/models/tenant.rs +++ b/src/console/models/tenant.rs @@ -145,3 +145,9 @@ pub struct UpdateTenantResponse { pub message: String, pub tenant: TenantListItem, } + +/// Tenant YAML 请求/响应 +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct TenantYAML { + pub yaml: String, +} diff --git a/src/console/openapi.rs b/src/console/openapi.rs index 13d4224..1ddca7c 100644 --- a/src/console/openapi.rs +++ b/src/console/openapi.rs @@ -37,7 +37,7 @@ use crate::console::models::pool::{ use crate::console::models::tenant::{ CreatePoolRequest, CreateTenantRequest, DeleteTenantResponse, EnvVar, LoggingConfig, PoolInfo, ServiceInfo, ServicePort, TenantDetailsResponse, TenantListItem, TenantListResponse, - UpdateTenantRequest, UpdateTenantResponse, + TenantYAML, UpdateTenantRequest, UpdateTenantResponse, }; #[derive(OpenApi)] @@ -52,6 +52,8 @@ use crate::console::models::tenant::{ api_get_tenant, api_update_tenant, api_delete_tenant, + api_get_tenant_yaml, + api_put_tenant_yaml, api_list_pools, api_add_pool, api_delete_pool, @@ -83,6 +85,7 @@ use crate::console::models::tenant::{ UpdateTenantRequest, UpdateTenantResponse, DeleteTenantResponse, + TenantYAML, PoolDetails, PoolListResponse, AddPoolRequest, @@ -170,6 +173,16 @@ fn api_delete_tenant() -> Json { unimplemented!("Documentation only") } +#[utoipa::path(get, path = "/api/v1/namespaces/{namespace}/tenants/{name}/yaml", params(("namespace" = String, Path), ("name" = String, Path)), responses((status = 200, body = TenantYAML)), tag = "tenants")] +fn api_get_tenant_yaml() -> Json { + unimplemented!("Documentation only") +} + +#[utoipa::path(put, path = "/api/v1/namespaces/{namespace}/tenants/{name}/yaml", params(("namespace" = String, Path), ("name" = String, Path)), request_body = TenantYAML, responses((status = 200, body = TenantYAML)), tag = "tenants")] +fn api_put_tenant_yaml(_body: Json) -> Json { + unimplemented!("Documentation only") +} + // --- Pools --- #[utoipa::path(get, path = "/api/v1/namespaces/{namespace}/tenants/{name}/pools", params(("namespace" = String, Path), ("name" = String, Path)), responses((status = 200, body = PoolListResponse)), tag = "pools")] fn api_list_pools() -> Json { diff --git a/src/console/routes/mod.rs b/src/console/routes/mod.rs index 774dcd9..a94da36 100755 --- a/src/console/routes/mod.rs +++ b/src/console/routes/mod.rs @@ -48,6 +48,14 @@ pub fn tenant_routes() -> Router { "/namespaces/:namespace/tenants/:name", delete(handlers::tenants::delete_tenant), ) + .route( + "/namespaces/:namespace/tenants/:name/yaml", + get(handlers::tenants::get_tenant_yaml), + ) + .route( + "/namespaces/:namespace/tenants/:name/yaml", + put(handlers::tenants::put_tenant_yaml), + ) } /// Pool 管理路由