diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..254d495 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build:*)", + "Bash(cargo test:*)", + "Bash(cargo clippy:*)" + ] + } +} 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/.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/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/Makefile b/Makefile index c88e745..7a6fb27 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -.PHONY: pre-commit fmt fmt-check clippy test build help console-lint console-fmt console-fmt-check +.PHONY: pre-commit fmt fmt-check clippy test build docker-build-operator docker-build-console-web docker-build-all help console-lint console-fmt console-fmt-check # 默认目标 help: @@ -25,6 +25,9 @@ help: @echo " make clippy - 运行 clippy 检查" @echo " make test - 运行 Rust 测试" @echo " make build - 构建项目" + @echo " make docker-build-operator - 构建 operator 镜像 (IMAGE_REPO?=rustfs/operator IMAGE_TAG?=dev)" + @echo " make docker-build-console-web - 构建 console-web 前端镜像 (CONSOLE_WEB_IMAGE_REPO?=rustfs/console-web CONSOLE_WEB_IMAGE_TAG?=dev)" + @echo " make docker-build-all - 构建 operator + console-web 两个镜像" @echo " make console-lint - 前端 ESLint 检查 (console-web)" @echo " make console-fmt - 前端 Prettier 自动格式化 (console-web)" @echo " make console-fmt-check - 前端 Prettier 格式检查 (console-web)" @@ -64,3 +67,16 @@ console-fmt-check: # 构建 build: cargo build --release + +# 构建 Docker 镜像(operator:含 controller + console API;console-web:前端静态资源) +IMAGE_REPO ?= rustfs/operator +IMAGE_TAG ?= dev +docker-build-operator: + docker build -t $(IMAGE_REPO):$(IMAGE_TAG) . + +CONSOLE_WEB_IMAGE_REPO ?= rustfs/console-web +CONSOLE_WEB_IMAGE_TAG ?= dev +docker-build-console-web: + docker build -t $(CONSOLE_WEB_IMAGE_REPO):$(CONSOLE_WEB_IMAGE_TAG) -f console-web/Dockerfile console-web + +docker-build-all: docker-build-operator docker-build-console-web diff --git a/console-web/.claude/settings.local.json b/console-web/.claude/settings.local.json new file mode 100644 index 0000000..ddd371b --- /dev/null +++ b/console-web/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(npx next:*)", + "mcp__ide__getDiagnostics", + "Bash(npx tsc:*)", + "WebFetch(domain:lh3.googleusercontent.com)", + "WebFetch(domain:lh3.google.com)", + "WebFetch(domain:gemini.google.com)", + "Bash(grep -l \"RiFolder\" node_modules/@remixicon/react/dist/*.mjs 2>/dev/null | head -3 || grep \"RiFolder\" node_modules/@remixicon/react/dist/index.d.ts 2>/dev/null | head -5 || echo \"try other path\"; find node_modules/@remixicon -name \"*.d.ts\" -maxdepth 3 2>/dev/null | head -3)" + ] + } +} 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 diff --git a/console-web/.nvmrc b/console-web/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/console-web/.nvmrc @@ -0,0 +1 @@ +22 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 deleted file mode 100644 index bea4b8b..0000000 --- a/console-web/app/(dashboard)/cluster/page.tsx +++ /dev/null @@ -1,277 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useTranslation } from "react-i18next" -import { toast } from "sonner" -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 { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Spinner } from "@/components/ui/spinner" -import * as api from "@/lib/api" -import type { NodeInfo, NamespaceItem, ClusterResourcesResponse } from "@/types/api" -import { ApiError } from "@/lib/api-client" - -type ClusterTab = "nodes" | "resources" | "namespaces" - -export default function ClusterPage() { - const { t } = useTranslation() - const [tab, setTab] = useState("nodes") - const [nodes, setNodes] = useState([]) - const [namespaces, setNamespaces] = useState([]) - const [resources, setResources] = useState(null) - const [loading, setLoading] = useState(true) - const [newNsOpen, setNewNsOpen] = useState(false) - const [newNsName, setNewNsName] = useState("") - const [createLoading, setCreateLoading] = useState(false) - - const load = async () => { - setLoading(true) - try { - const [nodeRes, nsRes, resRes] = await Promise.all([ - api.listNodes(), - api.listNamespaces(), - api.getClusterResources(), - ]) - setNodes(nodeRes.nodes) - setNamespaces(nsRes.namespaces) - setResources(resRes) - } catch (e) { - const err = e as ApiError - toast.error(err.message || t("Failed to load cluster data")) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load() - }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount - - const handleCreateNamespace = async (e: React.FormEvent) => { - e.preventDefault() - if (!newNsName.trim()) { - toast.warning(t("Namespace name is required")) - return - } - setCreateLoading(true) - try { - await api.createNamespace(newNsName.trim()) - toast.success(t("Namespace created")) - setNewNsOpen(false) - setNewNsName("") - load() - } catch (e) { - const err = e as ApiError - toast.error(err.message || t("Create failed")) - } finally { - setCreateLoading(false) - } - } - - const tabs: { id: ClusterTab; labelKey: string }[] = [ - { id: "nodes", labelKey: "Nodes" }, - { id: "resources", labelKey: "Resources" }, - { id: "namespaces", labelKey: "Namespaces" }, - ] - - return ( - - -

{t("Cluster")}

-

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

-
- -
- {tabs.map(({ id, labelKey }) => ( - - ))} -
- - {loading ? ( -
- -
- ) : ( - <> - {tab === "nodes" && ( -
- - - - {t("Name")} - {t("Status")} - {t("Roles")} - {t("CPU Capacity")} - {t("Memory Capacity")} - {t("CPU Allocatable")} - {t("Memory Allocatable")} - - - - {nodes.length === 0 ? ( - - - {t("No nodes")} - - - ) : ( - nodes.map((node) => ( - - {node.name} - {node.status} - {node.roles.join(", ") || "-"} - {node.cpu_capacity} - {node.memory_capacity} - {node.cpu_allocatable} - {node.memory_allocatable} - - )) - )} - -
-
- )} - - {tab === "resources" && resources && ( -
- - - {t("Total Nodes")} - - -

{resources.total_nodes}

-
-
- - - {t("Total CPU")} - - -

{resources.total_cpu}

-
-
- - - {t("Total Memory")} - - -

{resources.total_memory}

-
-
- - - {t("Allocatable")} - - -

CPU: {resources.allocatable_cpu}

-

Memory: {resources.allocatable_memory}

-
-
-
- )} - - {tab === "namespaces" && ( -
-
- -
- {newNsOpen && ( - - - {t("Create Namespace")} - {t("Create a new Kubernetes namespace.")} - - -
-
- - setNewNsName(e.target.value)} - placeholder="my-namespace" - /> -
- - -
-
-
- )} -
- - - - {t("Name")} - {t("Status")} - {t("Created")} - - - - {namespaces.map((ns) => ( - - {ns.name} - {ns.status} - - {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..089b3e8 100755 --- a/console-web/app/(dashboard)/layout.tsx +++ b/console-web/app/(dashboard)/layout.tsx @@ -8,7 +8,6 @@ import { RiDashboardLine, RiServerLine, RiLogoutBoxLine, - RiNodeTree, RiQuestionLine, RiGithubLine, RiTwitterXLine, @@ -22,24 +21,14 @@ 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" }, { href: routes.tenants, icon: RiServerLine, labelKey: "Tenants" }, - { 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 +48,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 +73,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 +101,6 @@ export default function DashboardLayout({ )} diff --git a/console-web/app/(dashboard)/page.tsx b/console-web/app/(dashboard)/page.tsx index 329eeb7..a24ce7d 100755 --- a/console-web/app/(dashboard)/page.tsx +++ b/console-web/app/(dashboard)/page.tsx @@ -1,22 +1,167 @@ "use client" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import Link from "next/link" -import { RiServerLine, RiNodeTree } from "@remixicon/react" +import { toast } from "sonner" +import { RiAddLine, RiArrowDownSLine, RiFolderLine, RiHardDrive2Line, RiNodeTree, RiServerLine } 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" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Spinner } from "@/components/ui/spinner" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import * as api from "@/lib/api" +import { ApiError } from "@/lib/api-client" +import { cn, formatBinaryBytes, formatK8sMemory } from "@/lib/utils" +import type { ClusterResourcesResponse, NamespaceItem, NodeInfo } from "@/types/api" +import type { TopologyOverviewResponse, TopologyTenantState } from "@/types/topology" + +type ClusterTab = "nodes" | "resources" | "namespaces" + +const STATE_THEME: Record< + TopologyTenantState, + { + badge: string + dot: string + card: string + } +> = { + Ready: { + badge: "border-emerald-200 bg-emerald-50 text-emerald-700", + dot: "bg-emerald-500", + card: "border-emerald-200 bg-emerald-50/60", + }, + Updating: { + badge: "border-blue-200 bg-blue-50 text-blue-700", + dot: "bg-blue-500", + card: "border-blue-200 bg-blue-50/60", + }, + Degraded: { + badge: "border-amber-200 bg-amber-50 text-amber-700", + dot: "bg-amber-500", + card: "border-amber-200 bg-amber-50/60", + }, + NotReady: { + badge: "border-red-200 bg-red-50 text-red-700", + dot: "bg-red-500", + card: "border-red-200 bg-red-50/60", + }, + Unknown: { + badge: "border-zinc-200 bg-zinc-100 text-zinc-700", + dot: "bg-zinc-500", + card: "border-zinc-200 bg-zinc-100/80", + }, +} + +function getTreeDotClass(state: string): string { + switch (state) { + case "Ready": + case "Running": + return "bg-emerald-500" + case "Updating": + return "bg-blue-500" + case "Degraded": + case "Pending": + return "bg-amber-500" + case "NotReady": + case "Failed": + return "bg-red-500" + default: + return "bg-zinc-400" + } +} export default function DashboardPage() { const { t } = useTranslation() + const [tab, setTab] = useState("nodes") + const [nodes, setNodes] = useState([]) + const [namespaces, setNamespaces] = useState([]) + const [resources, setResources] = useState(null) + const [topology, setTopology] = useState(null) + const [loading, setLoading] = useState(true) + const [newNsOpen, setNewNsOpen] = useState(false) + const [newNsName, setNewNsName] = useState("") + const [createLoading, setCreateLoading] = useState(false) + const [treeCollapsed, setTreeCollapsed] = useState>(new Set()) + + const toggleTreeNode = (id: string) => { + setTreeCollapsed((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + const isTreeExpanded = (id: string) => !treeCollapsed.has(id) + + const load = async () => { + setLoading(true) + try { + const [nodeRes, nsRes, resRes, topologyRes] = await Promise.all([ + api.listNodes(), + api.listNamespaces(), + api.getClusterResources(), + api.getTopologyOverview(), + ]) + setNodes(nodeRes.nodes) + setNamespaces(nsRes.namespaces) + setResources(resRes) + setTopology(topologyRes) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load cluster data")) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + + const handleCreateNamespace = async (e: React.FormEvent) => { + e.preventDefault() + if (!newNsName.trim()) { + toast.warning(t("Namespace name is required")) + return + } + setCreateLoading(true) + try { + await api.createNamespace(newNsName.trim()) + toast.success(t("Namespace created")) + setNewNsOpen(false) + setNewNsName("") + load() + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Create failed")) + } finally { + setCreateLoading(false) + } + } + + const tabs: { id: ClusterTab; labelKey: string }[] = [ + { id: "nodes", labelKey: "Nodes" }, + { id: "resources", labelKey: "Resources" }, + { id: "namespaces", labelKey: "Namespaces" }, + ] + + const topologySummary = topology?.cluster.summary + const tenantCount = topology?.namespaces.reduce((sum, ns) => sum + ns.tenants.length, 0) ?? 0 + const unhealthyCount = + topology?.namespaces.reduce((sum, ns) => sum + ns.tenants.filter((t) => t.state !== "Ready").length, 0) ?? 0 + + const allPods = topology?.namespaces.flatMap((ns) => ns.tenants.flatMap((t) => t.pods ?? [])) ?? [] + const podTotal = allPods.length + const podRunning = allPods.filter((p) => p.phase === "Running").length + + const totalCapacityBytes = + topology?.namespaces.reduce( + (sum, ns) => sum + ns.tenants.reduce((s, t) => s + (t.summary.capacity_bytes ?? 0), 0), + 0, + ) ?? 0 return ( @@ -24,7 +169,7 @@ export default function DashboardPage() {

{t("Dashboard")}

-
+
@@ -35,30 +180,368 @@ export default function DashboardPage() { {t("Manage RustFS tenants: create, view, edit pools and pods.")} - - + +
+

{t("Tenants")}

+
+ {topologySummary?.tenants ?? tenantCount} + {unhealthyCount > 0 && ( + + {unhealthyCount} {t("unhealthy")} + + )} +
+
+
+

{t("Pods")}

+
+ {podTotal} + + {podRunning} {t("running")} + +
+
+
+

{t("Capacity")}

+

{formatBinaryBytes(totalCapacityBytes)}

+
+
- + {topology && topology.namespaces.length > 0 && ( + + +
+ + {t("Cluster Topology")} +
+
+ +
+ {/* Cluster root */} +
+ + + {isTreeExpanded("cluster") && ( +
+ {topology.namespaces.map((ns) => { + const nsId = `ns:${ns.name}` + const nsHasIssues = ns.unhealthy_tenant_count > 0 + return ( +
+ + + {isTreeExpanded(nsId) && ( +
+ {ns.tenants.map((tenant) => { + const tenantId = `t:${ns.name}/${tenant.name}` + const pools = tenant.pools ?? [] + const pods = tenant.pods ?? [] + return ( +
+ + + {isTreeExpanded(tenantId) && pools.length > 0 && ( +
+ {pools.map((pool) => { + const poolId = `p:${ns.name}/${tenant.name}/${pool.name}` + const poolPods = pods.filter((p) => p.pool === pool.name) + return ( +
+ + + {isTreeExpanded(poolId) && poolPods.length > 0 && ( +
+ {poolPods.map((pod) => ( +
+
+

{pod.name}

+
+ {t("Phase")} + + {pod.phase} + + {t("Node")} + {pod.node ?? "-"} + {t("Ready")} + {pod.ready} +
+
+
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} +
+
+
+
+ )} + +
+
{t("Cluster")}
- - {t("View cluster nodes, resources and namespaces.")} - + {t("Cluster nodes, capacity and namespaces.")}
- - + +
+ {tabs.map(({ id, labelKey }) => ( + + ))} +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {tab === "nodes" && ( +
+ + + + {t("Name")} + {t("Status")} + {t("Roles")} + {t("CPU Capacity")} + {t("Memory Capacity")} + {t("CPU Allocatable")} + {t("Memory Allocatable")} + + + + {nodes.length === 0 ? ( + + + {t("No nodes")} + + + ) : ( + nodes.map((node) => ( + + {node.name} + {node.status} + {node.roles.join(", ") || "-"} + {node.cpu_capacity} + {formatK8sMemory(node.memory_capacity)} + {node.cpu_allocatable} + {formatK8sMemory(node.memory_allocatable)} + + )) + )} + +
+
+ )} + + {tab === "resources" && resources && ( +
+ + + {t("Total Nodes")} + + +

{resources.total_nodes}

+
+
+ + + {t("Total CPU")} + + +

{resources.total_cpu}

+
+
+ + + {t("Total Memory")} + + +

{formatK8sMemory(resources.total_memory)}

+
+
+ + + {t("Allocatable")} + + +

CPU: {resources.allocatable_cpu}

+

Memory: {formatK8sMemory(resources.allocatable_memory)}

+
+
+
+ )} + + {tab === "namespaces" && ( +
+
+ +
+ {newNsOpen && ( + + + {t("Create Namespace")} + {t("Create a new Kubernetes namespace.")} + + +
+
+ + setNewNsName(e.target.value)} + placeholder="my-namespace" + /> +
+ + +
+
+
+ )} +
+ + + + {t("Name")} + {t("Status")} + {t("Created")} + + + + {namespaces.map((ns) => ( + + {ns.name} + {ns.status} + + {ns.created_at ? new Date(ns.created_at).toLocaleString() : "-"} + + + ))} + +
+
+
+ )} + + )}
-
+ ) } 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..3854471 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 @@ -9,27 +9,15 @@ import { RiArrowLeftLine, RiDeleteBinLine, RiAddLine, + RiFileCopyLine, 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" @@ -41,22 +29,45 @@ import type { PodListItem, EventItem, AddPoolRequest, - UpdateTenantRequest, + EncryptionInfoResponse, + UpdateEncryptionRequest, } from "@/types/api" import { ApiError } from "@/lib/api-client" -type Tab = "overview" | "edit" | "pools" | "pods" | "events" +type Tab = "overview" | "edit" | "pools" | "pods" | "events" | "encryption" | "security" interface TenantDetailClientProps { namespace: string name: string + initialTab?: string | null + initialYamlEditable?: boolean +} + +function normalizeTab(value?: string | null): Tab { + switch ((value ?? "").toLowerCase()) { + case "edit": + case "yaml": + return "edit" + case "pools": + return "pools" + case "pods": + return "pods" + case "events": + return "events" + case "encryption": + return "encryption" + case "security": + return "security" + default: + return "overview" + } } -export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) { +export function TenantDetailClient({ namespace, name, initialTab, initialYamlEditable }: TenantDetailClientProps) { const router = useRouter() const { t } = useTranslation() - const [tab, setTab] = useState("overview") + const [tab, setTab] = useState(() => normalizeTab(initialTab)) const [tenant, setTenant] = useState(null) const [pools, setPools] = useState([]) const [pods, setPods] = useState([]) @@ -78,9 +89,50 @@ 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 [tenantYamlSnapshot, setTenantYamlSnapshot] = useState("") + const [tenantYamlLoaded, setTenantYamlLoaded] = useState(false) + const [tenantYamlLoading, setTenantYamlLoading] = useState(false) + const [isYamlEditable, setIsYamlEditable] = useState(!!initialYamlEditable) const [editLoading, setEditLoading] = useState(false) + // Encryption tab state + const [encLoaded, setEncLoaded] = useState(false) + const [encLoading, setEncLoading] = useState(false) + const [encSaving, setEncSaving] = useState(false) + const [encEnabled, setEncEnabled] = useState(false) + const [encBackend, setEncBackend] = useState<"local" | "vault">("local") + const [encVault, setEncVault] = useState({ + endpoint: "", + engine: "", + namespace: "", + prefix: "", + authType: "token", + tlsSkipVerify: false, + customCertificates: false, + }) + const [encAppRole, setEncAppRole] = useState({ + engine: "", + retrySeconds: "", + }) + const [encLocal, setEncLocal] = useState({ + keyDirectory: "", + masterKeyId: "", + }) + const [encKmsSecretName, setEncKmsSecretName] = useState("") + const [encPingSeconds, setEncPingSeconds] = useState("") + + // Security tab state + const [secCtxLoaded, setSecCtxLoaded] = useState(false) + const [secCtxLoading, setSecCtxLoading] = useState(false) + const [secCtxSaving, setSecCtxSaving] = useState(false) + const [secCtx, setSecCtx] = useState({ + runAsUser: "", + runAsGroup: "", + fsGroup: "", + runAsNonRoot: true, + }) + const loadTenant = async () => { const [detailResult, poolResult, podResult, eventResult] = await Promise.allSettled([ api.getTenant(namespace, name), @@ -113,10 +165,90 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) setLoading(false) } + const loadTenantYaml = async () => { + setTenantYamlLoading(true) + try { + const res = await api.getTenantYaml(namespace, name) + setTenantYaml(res.yaml) + setTenantYamlSnapshot(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("") + setTenantYamlSnapshot("") + setTenantYamlLoaded(false) + setIsYamlEditable(!!initialYamlEditable) + }, [namespace, name, initialYamlEditable]) + + useEffect(() => { + setTab(normalizeTab(initialTab)) + setIsYamlEditable(!!initialYamlEditable) + }, [namespace, name, initialTab, initialYamlEditable]) + + useEffect(() => { + if (tab !== "edit" || tenantYamlLoaded || tenantYamlLoading) return + loadTenantYaml() + }, [tab, tenantYamlLoaded, tenantYamlLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + + useEffect(() => { + if (tab !== "encryption" || encLoaded || encLoading) return + loadEncryption() + }, [tab, encLoaded, encLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + + useEffect(() => { + if (tab !== "security" || secCtxLoaded || secCtxLoading) return + loadSecurityContext() + }, [tab, secCtxLoaded, secCtxLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + + const loadSecurityContext = async () => { + setSecCtxLoading(true) + try { + const data = await api.getSecurityContext(namespace, name) + setSecCtx({ + runAsUser: data.runAsUser?.toString() ?? "", + runAsGroup: data.runAsGroup?.toString() ?? "", + fsGroup: data.fsGroup?.toString() ?? "", + runAsNonRoot: data.runAsNonRoot ?? true, + }) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load security context")) + } finally { + setSecCtxLoaded(true) + setSecCtxLoading(false) + } + } + + const handleSaveSecurityContext = async (e: React.FormEvent) => { + e.preventDefault() + setSecCtxSaving(true) + try { + await api.updateSecurityContext(namespace, name, { + runAsUser: secCtx.runAsUser ? parseInt(secCtx.runAsUser, 10) : undefined, + runAsGroup: secCtx.runAsGroup ? parseInt(secCtx.runAsGroup, 10) : undefined, + fsGroup: secCtx.fsGroup ? parseInt(secCtx.fsGroup, 10) : undefined, + runAsNonRoot: secCtx.runAsNonRoot, + }) + toast.success(t("SecurityContext updated")) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Update failed")) + } finally { + setSecCtxSaving(false) + } + } + const handleDeleteTenant = async () => { if (!confirm(t("Delete this tenant? This cannot be undone."))) return setDeleting(true) @@ -152,7 +284,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 +313,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) @@ -213,22 +345,20 @@ 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) + setTenantYamlSnapshot(res.yaml) + setIsYamlEditable(false) + toast.success(t("Tenant YAML updated")) loadTenant() - setEditForm({}) } catch (e) { const err = e as ApiError toast.error(err.message || t("Update failed")) @@ -237,6 +367,101 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) } } + const loadEncryption = async () => { + setEncLoading(true) + try { + const data = await api.getEncryption(namespace, name) + setEncEnabled(data.enabled) + setEncBackend((data.backend === "vault" ? "vault" : "local") as "local" | "vault") + if (data.vault) { + setEncVault({ + endpoint: data.vault.endpoint || "", + engine: data.vault.engine || "", + namespace: data.vault.namespace || "", + prefix: data.vault.prefix || "", + authType: data.vault.authType || "token", + tlsSkipVerify: data.vault.tlsSkipVerify || false, + customCertificates: data.vault.customCertificates || false, + }) + if (data.vault.appRole) { + setEncAppRole({ + engine: data.vault.appRole.engine || "", + retrySeconds: data.vault.appRole.retrySeconds?.toString() || "", + }) + } + } + if (data.local) { + setEncLocal({ + keyDirectory: data.local.keyDirectory || "", + masterKeyId: data.local.masterKeyId || "", + }) + } + setEncKmsSecretName(data.kmsSecretName || "") + setEncPingSeconds(data.pingSeconds?.toString() || "") + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Failed to load encryption config")) + } finally { + setEncLoaded(true) + setEncLoading(false) + } + } + + const handleSaveEncryption = async (e: React.FormEvent) => { + e.preventDefault() + if (encEnabled && encBackend === "vault" && !encVault.endpoint.trim()) { + toast.warning(t("Vault endpoint is required")) + return + } + setEncSaving(true) + try { + const body: UpdateEncryptionRequest = { + enabled: encEnabled, + backend: encBackend, + kmsSecretName: encKmsSecretName || undefined, + pingSeconds: encPingSeconds ? parseInt(encPingSeconds, 10) : undefined, + } + if (encBackend === "vault") { + body.vault = { + endpoint: encVault.endpoint, + engine: encVault.engine || undefined, + namespace: encVault.namespace || undefined, + prefix: encVault.prefix || undefined, + authType: encVault.authType || undefined, + tlsSkipVerify: encVault.tlsSkipVerify || undefined, + customCertificates: encVault.customCertificates || undefined, + } + if (encVault.authType === "approle") { + body.vault.appRole = { + engine: encAppRole.engine || undefined, + retrySeconds: encAppRole.retrySeconds ? parseInt(encAppRole.retrySeconds, 10) : undefined, + } + } + } else { + body.local = { + keyDirectory: encLocal.keyDirectory || undefined, + masterKeyId: encLocal.masterKeyId || undefined, + } + } + const res = await api.updateEncryption(namespace, name, body) + toast.success(res.message || t("Encryption config updated")) + } catch (e) { + const err = e as ApiError + toast.error(err.message || t("Update failed")) + } finally { + setEncSaving(false) + } + } + + const handleCopyYaml = async () => { + try { + await navigator.clipboard.writeText(tenantYaml) + toast.success(t("YAML copied")) + } catch { + toast.error(t("Copy failed")) + } + } + if (loading || !tenant) { return (
@@ -247,10 +472,12 @@ 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: "encryption", labelKey: "Encryption" }, + { id: "security", labelKey: "Security" }, + { id: "edit", labelKey: "YAML" }, ] return ( @@ -264,12 +491,7 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps) {t("Back")} - @@ -279,7 +501,9 @@ export function TenantDetailClient({ namespace, name }: TenantDetailClientProps)

{tenant.name} / {tenant.namespace}

-

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

+

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

@@ -306,9 +530,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 +564,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(", ")} ))} @@ -352,42 +579,62 @@ 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" - /> + {tenantYamlLoading ? ( +
+ + {t("Loading tenant YAML...")}
-
- - setEditForm((f) => ({ ...f, mount_path: e.target.value }))} - placeholder="/data/rustfs" - /> -
-
- - setEditForm((f) => ({ ...f, creds_secret: e.target.value }))} - placeholder="" - /> -
- - + ) : ( +
+
+ + +
+
+ +