From 7a1968d14d7c171d9affaaf779d65b6c54618885 Mon Sep 17 00:00:00 2001 From: jhw <835269233@qq.com> Date: Sun, 8 Mar 2026 22:33:51 +0800 Subject: [PATCH 01/16] feat(console): add GET/PUT tenant YAML API endpoint Add /api/v1/namespaces/{namespace}/tenants/{name}/yaml for reading and editing the full Tenant CR as YAML (similar to MinIO operator). GET: returns the complete Tenant CR serialized as YAML (managedFields stripped for readability). PUT: accepts edited YAML, validates name/namespace match, pool uniqueness, and safely updates only spec/labels/annotations/finalizers while preserving resourceVersion and immutable metadata. Also applies Prettier formatting to existing console-web files. Made-with: Cursor --- console-web/README.md | 8 +- console-web/app/(auth)/auth/login/page.tsx | 5 +- console-web/app/(auth)/layout.tsx | 12 +- console-web/app/(dashboard)/cluster/page.tsx | 25 +--- console-web/app/(dashboard)/layout.tsx | 22 +--- console-web/app/(dashboard)/page.tsx | 20 ++-- .../[name]/tenant-detail-client.tsx | 88 ++++++-------- .../app/(dashboard)/tenants/new/page.tsx | 44 ++++--- console-web/app/(dashboard)/tenants/page.tsx | 25 ++-- console-web/components/page-header.tsx | 7 +- console-web/components/ui/dropdown-menu.tsx | 27 ++++- console-web/lib/api.ts | 78 +++---------- console-web/tsconfig.json | 9 +- console-web/types/api.ts | 5 +- src/console/handlers/tenants.rs | 107 ++++++++++++++++++ src/console/models/tenant.rs | 6 + src/console/openapi.rs | 15 ++- src/console/routes/mod.rs | 8 ++ 18 files changed, 267 insertions(+), 244 deletions(-) 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 管理路由 From 57aa44e2ce7ec7153814232c07da21d878948474 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Tue, 10 Mar 2026 16:31:20 +0800 Subject: [PATCH 02/16] fix(console-web): add .dockerignore to fix Docker build (#97) * feat(console): add GET/PUT tenant YAML API endpoint and PR template Add /api/v1/namespaces/{namespace}/tenants/{name}/yaml for reading and editing the full Tenant CR as YAML (similar to MinIO operator). GET: returns the complete Tenant CR serialized as YAML (managedFields stripped for readability). PUT: accepts edited YAML, validates name/namespace match, pool uniqueness, and safely updates only spec/labels/annotations/finalizers while preserving resourceVersion and immutable metadata. Also adds AGENTS.md (global + .github), PR template, and applies Prettier formatting to existing console-web files. Made-with: Cursor * fix(console-web): add .dockerignore to fix Docker build (node_modules copy conflict) Made-with: Cursor --- .github/AGENTS.md | 30 ++++++++++++++ .github/pull_request_template.md | 44 ++++++++++++++++++++ AGENTS.md | 69 ++++++++++++++++++++++++++++++++ console-web/.dockerignore | 24 +++++++++++ 4 files changed, 167 insertions(+) create mode 100644 .github/AGENTS.md create mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 100644 console-web/.dockerignore diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000..a114120 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,30 @@ +# GitHub Workflow Instructions + +Applies to `.github/` and repository pull-request operations. + +## Pull Requests + +- PR titles and descriptions must be in English. +- Use `.github/pull_request_template.md` for every PR body. +- Keep all template section headings. +- Use `N/A` for non-applicable sections. +- Include verification commands in the PR details. +- For `gh pr create` and `gh pr edit`, always write markdown body to a file and pass `--body-file`. +- Do not use multiline inline `--body`; backticks and shell expansion can corrupt content or trigger unintended commands. +- Recommended pattern: + - `cat > /tmp/pr_body.md <<'EOF'` + - `...markdown...` + - `EOF` + - `gh pr create ... --body-file /tmp/pr_body.md` + +## CI Alignment + +When changing CI-sensitive behavior, keep local validation aligned with `.github/workflows/ci.yml`. + +Current `test-and-lint` gate includes: + +- `cargo fmt --all --check` +- `cargo clippy --all-features -- -D warnings` +- `cargo test --all` +- `cd console-web && npm run lint` +- `cd console-web && npx prettier --check "**/*.{ts,tsx,js,jsx,json,css,md}"` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ca582b2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,44 @@ + + +## Type of Change +- [ ] New Feature +- [ ] Bug Fix +- [ ] Documentation +- [ ] Performance Improvement +- [ ] Test/CI +- [ ] Refactor +- [ ] Other: + +## Related Issues + + +## Summary of Changes + + +## Checklist +- [ ] I have read and followed the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines +- [ ] Passed `make pre-commit` (fmt-check + clippy + test + console-lint + console-fmt-check) +- [ ] Added/updated necessary tests +- [ ] Documentation updated (if needed) +- [ ] CHANGELOG.md updated under `[Unreleased]` (if user-visible change) +- [ ] CI/CD passed (if applicable) + +## Impact +- [ ] Breaking change (CRD/API compatibility) +- [ ] Requires doc/config/deployment update +- [ ] Other impact: + +## Verification + +```bash +make pre-commit +``` + +## Additional Notes + + +--- + +Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)) and sign the CLA if this is your first contribution. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da56e58 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# RustFS Operator Agent Instructions (Global) + +This root file keeps repository-wide rules only. +Use the nearest subdirectory `AGENTS.md` for path-specific guidance. + +## Rule Precedence + +1. System/developer instructions. +2. This file (global defaults). +3. The nearest `AGENTS.md` in the current path (more specific scope wins). + +If repo-level instructions conflict, follow the nearest file and keep behavior aligned with CI. + +## Communication and Language + +- Respond in the same language used by the requester. +- Keep source code, comments, commit messages, and PR title/body in English. + +## Sources of Truth + +- Workspace layout: `Cargo.toml` +- Project overview and architecture: `CLAUDE.md` +- Local quality commands: `Makefile` +- CI quality gates: `.github/workflows/ci.yml` +- PR template: `.github/pull_request_template.md` + +Avoid duplicating long command matrices in instruction files. +Reference the source files above instead. + +## Mandatory Before Commit + +Run and pass: + +```bash +make pre-commit +``` + +This runs: `fmt-check` → `clippy` → `test` → `console-lint` → `console-fmt-check`. + +Do not commit when required checks fail. + +## Git and PR Baseline + +- Use feature branches based on the latest `main`. +- Follow Conventional Commits, with subject length <= 72 characters. +- Keep PR title and description in English. +- Use `.github/pull_request_template.md` and keep all section headings. +- Use `N/A` for non-applicable template sections. +- Include verification commands in the PR description. +- When using `gh pr create`/`gh pr edit`, use `--body-file` instead of inline `--body` for multiline markdown: + ```bash + cat > /tmp/pr_body.md <<'EOF' + ...markdown... + EOF + gh pr create ... --body-file /tmp/pr_body.md + ``` + +## Security Baseline + +- Never commit secrets, credentials, or key material. +- Use environment variables or vault tooling for sensitive configuration. +- Credential Secrets must contain `accesskey` and `secretkey` keys (both valid UTF-8, minimum 8 characters). + +## Architecture Constraints + +- All pools within a single Tenant form ONE unified RustFS cluster. +- Do not assume pool-based storage tiering. For separate clusters, use separate Tenants. +- Error handling uses `snafu`. New files must include Apache 2.0 license headers. +- Do not invent RustFS ports/constants; verify against `CLAUDE.md` and official sources. diff --git a/console-web/.dockerignore b/console-web/.dockerignore new file mode 100644 index 0000000..53dec02 --- /dev/null +++ b/console-web/.dockerignore @@ -0,0 +1,24 @@ +# Dependencies: install inside container; avoid copying host node_modules (pnpm symlinks break COPY) +node_modules + +# Build outputs +.next +out +build + +# Git and IDE +.git +.gitignore +.vscode +.DS_Store + +# Env and debug +.env* +*.pem +*.log + +# Test and misc +coverage +.vercel +*.tsbuildinfo +next-env.d.ts From b4119d6931182c38926ae2669d75e103466c8c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Tue, 10 Mar 2026 17:36:24 +0800 Subject: [PATCH 03/16] feat : Tenant yaml edit --- .gitignore | 3 +- .../[name]/tenant-detail-client.tsx | 109 +++-- .../app/(dashboard)/tenants/new/page.tsx | 420 ++++++++++++------ console-web/i18n/locales/en-US.json | 17 + console-web/i18n/locales/zh-CN.json | 17 + console-web/lib/api.ts | 14 + console-web/package-lock.json | 17 +- console-web/package.json | 3 +- console-web/types/api.ts | 4 + 9 files changed, 422 insertions(+), 182 deletions(-) diff --git a/.gitignore b/.gitignore index 36d91ce..aa2fd74 100755 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ console-web/node_modules/ # Docs / summaries (local or generated) CONSOLE-INTEGRATION-SUMMARY.md -SCRIPTS-UPDATE.md \ No newline at end of file +SCRIPTS-UPDATE.md +AGENTS.md \ No newline at end of file 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 f97d1f3..d4105ac 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 @@ -22,7 +22,6 @@ import type { PodListItem, EventItem, AddPoolRequest, - UpdateTenantRequest, } from "@/types/api" import { ApiError } from "@/lib/api-client" @@ -59,7 +58,9 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) const [logsPod, setLogsPod] = useState(null) const [logsContent, setLogsContent] = useState("") const [logsLoading, setLogsLoading] = useState(false) - const [editForm, setEditForm] = useState({}) + const [tenantYaml, setTenantYaml] = useState("") + const [tenantYamlLoaded, setTenantYamlLoaded] = useState(false) + const [tenantYamlLoading, setTenantYamlLoading] = useState(false) const [editLoading, setEditLoading] = useState(false) const loadTenant = async () => { @@ -94,10 +95,34 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) setLoading(false) } + const loadTenantYaml = async () => { + setTenantYamlLoading(true) + try { + const res = await api.getTenantYaml(namespace, name) + setTenantYaml(res.yaml) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load tenant YAML")) + } finally { + setTenantYamlLoaded(true) + setTenantYamlLoading(false) + } + } + useEffect(() => { loadTenant() }, [namespace, name]) // eslint-disable-line react-hooks/exhaustive-deps -- reload when route params change + useEffect(() => { + setTenantYaml("") + setTenantYamlLoaded(false) + }, [namespace, name]) + + useEffect(() => { + if (tab !== "edit" || tenantYamlLoaded || tenantYamlLoading) return + loadTenantYaml() + }, [tab, tenantYamlLoaded, tenantYamlLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + const handleDeleteTenant = async () => { if (!confirm(t("Delete this tenant? This cannot be undone."))) return setDeleting(true) @@ -194,22 +219,18 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) } } - const handleUpdateTenant = async (e: React.FormEvent) => { + const handleUpdateTenantYaml = async (e: React.FormEvent) => { e.preventDefault() - const body: UpdateTenantRequest = {} - if (editForm.image !== undefined) body.image = editForm.image || undefined - if (editForm.mount_path !== undefined) body.mount_path = editForm.mount_path || undefined - if (editForm.creds_secret !== undefined) body.creds_secret = editForm.creds_secret || undefined - if (Object.keys(body).length === 0) { - toast.warning(t("No changes to save")) + if (!tenantYaml.trim()) { + toast.warning(t("YAML content is required")) return } setEditLoading(true) try { - await api.updateTenant(namespace, name, body) - toast.success(t("Tenant updated")) + const res = await api.updateTenantYaml(namespace, name, { yaml: tenantYaml }) + setTenantYaml(res.yaml) + toast.success(t("Tenant YAML updated")) loadTenant() - setEditForm({}) } catch (e) { const err = e as ApiError toast.error(err.message || t("Update failed")) @@ -228,10 +249,10 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) const tabs: { id: Tab; labelKey: string }[] = [ { id: "overview", labelKey: "Overview" }, - { id: "edit", labelKey: "Edit" }, { id: "pools", labelKey: "Pools" }, { id: "pods", labelKey: "Pods" }, { id: "events", labelKey: "Events" }, + { id: "edit", labelKey: "YAML" }, ] return ( @@ -333,42 +354,38 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {t("Edit Tenant")} - {t("Update tenant image, mount path or credentials secret.")} + {t("Update tenant by editing YAML directly.")} -
-
- - setEditForm((f) => ({ ...f, image: e.target.value }))} - placeholder="rustfs/rustfs:latest" - /> -
-
- - setEditForm((f) => ({ ...f, mount_path: e.target.value }))} - placeholder="/data/rustfs" - /> -
-
- - setEditForm((f) => ({ ...f, creds_secret: e.target.value }))} - placeholder="" - /> + {tenantYamlLoading ? ( +
+ + {t("Loading tenant YAML...")}
- - + ) : ( +
+
+ +