- {t("No tenants yet. Create one to get started.")}
+ ) : filteredTenants.length === 0 ? (
+
+
- {t("Namespace")}
{t("Name")}
- {t("State")}
- {t("Pools")}
+ {t("Namespace")}
+ {t("Status")}
+ {t("Replicas")}
+ {t("Version")}
+ {t("Total Capacity")}
+ {t("Endpoint")}
{t("Created")}
- {t("Actions")}
+ {t("Actions")}
- {tenants.map((tnt) => (
-
- {tnt.namespace}
-
-
- {tnt.name}
-
-
- {tnt.state}
-
- {tnt.pools.length === 0
- ? "-"
- : tnt.pools.map((p) => p.name).join(", ")}
-
-
- {tnt.created_at
- ? new Date(tnt.created_at).toLocaleString()
- : "-"}
-
-
-
+ {filteredTenants.map((tenant) => {
+ const key = makeTenantKey(tenant.namespace, tenant.name)
+ const meta = tenantMeta[key]
+ const normalizedState = normalizeTenantState(tenant.state)
+ return (
+
+
-
+ {tenant.name}
-
+ {tenant.namespace}
+
+
- {deleting === `${tnt.namespace}/${tnt.name}` ? (
-
- ) : (
-
- )}
-
-
-
-
- ))}
+ {t(normalizedState)}
+
+
+ {meta?.replicas ?? "-"}
+ {meta?.version ?? "-"}
+ {meta?.capacity ?? "-"}
+
+ {meta?.endpoint ?? "-"}
+
+
+ {tenant.created_at ? new Date(tenant.created_at).toLocaleDateString() : "-"}
+
+
+
+
+
+
+
+ router.push(routes.tenantDetail(tenant.namespace, tenant.name))}
+ >
+
+ {t("View Details")}
+
+
+ router.push(`${routes.tenantDetail(tenant.namespace, tenant.name)}&tab=yaml&editable=1`)
+ }
+ >
+
+ {t("Edit")}
+
+ handleCopyTenantName(tenant.name)}>
+
+ {t("Copy Name")}
+
+ handleOpenConsole(meta?.endpoint ?? "-")}>
+
+ {t("Open Console")}
+
+
+ handleDelete(tenant.namespace, tenant.name)}
+ disabled={deleting === key}
+ >
+
+ {t("Delete")}
+
+
+
+
+
+ )
+ })}
+ {metaLoading && (
+
+
+ {t("Loading tenant metrics...")}
+
+ )}
)}
diff --git a/console-web/app/globals.css b/console-web/app/globals.css
index 568955b..c5697c9 100755
--- a/console-web/app/globals.css
+++ b/console-web/app/globals.css
@@ -133,6 +133,420 @@
}
}
+@layer components {
+ /* ===== Vertical Org-Chart Tree ===== */
+
+ .vtree-card {
+ position: relative;
+ overflow: hidden;
+ }
+
+ .vtree-card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(ellipse 60% 40% at 0% 0%, color-mix(in oklab, var(--primary) 3%, transparent), transparent),
+ radial-gradient(ellipse 40% 50% at 100% 100%, color-mix(in oklab, var(--primary) 2%, transparent), transparent);
+ }
+
+ .dark .vtree-card::before {
+ background:
+ radial-gradient(ellipse 60% 40% at 0% 0%, color-mix(in oklab, white 2%, transparent), transparent),
+ radial-gradient(ellipse 40% 50% at 100% 100%, color-mix(in oklab, white 1.5%, transparent), transparent);
+ }
+
+ /* Root container: centers the tree */
+ .vtree-v {
+ display: flex;
+ justify-content: center;
+ min-width: fit-content;
+ padding: 0 1rem;
+ }
+
+ /* Every node is a vertical flex column */
+ .vtree-vnode {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ }
+
+ /* ---- Node box (shared base) ---- */
+ .vtree-vbox {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4375rem 0.875rem;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ background: var(--card);
+ cursor: pointer;
+ font-size: 13px;
+ white-space: nowrap;
+ transition:
+ box-shadow 0.15s ease,
+ border-color 0.15s ease;
+ }
+
+ .vtree-vbox:hover {
+ box-shadow: 0 2px 8px color-mix(in oklab, var(--foreground) 6%, transparent);
+ border-color: color-mix(in oklab, var(--border) 50%, var(--primary) 50%);
+ }
+
+ /* Level variants */
+ .vtree-vbox--cluster {
+ padding: 0.5rem 1rem;
+ font-weight: 600;
+ font-size: 14px;
+ border-color: color-mix(in oklab, var(--primary) 30%, var(--border) 70%);
+ box-shadow: 0 1px 3px color-mix(in oklab, var(--primary) 6%, transparent);
+ }
+
+ .vtree-vbox--ns {
+ color: oklch(0.5 0.18 260);
+ }
+
+ .dark .vtree-vbox--ns {
+ color: oklch(0.78 0.14 260);
+ }
+
+ .vtree-vbox--tenant {
+ gap: 0.375rem;
+ }
+
+ .vtree-vbox--pool {
+ font-size: 12px;
+ padding: 0.375rem 0.75rem;
+ gap: 0.375rem;
+ }
+
+ .vtree-vbox--alert {
+ border-color: color-mix(in oklab, oklch(0.7 0.2 30) 35%, var(--border) 65%);
+ }
+
+ .vtree-vbox-name {
+ font-weight: 500;
+ }
+
+ /* Chevron inside the box */
+ .vtree-vchevron {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ opacity: 0.4;
+ transition:
+ transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.15s;
+ }
+
+ .vtree-vbox:hover .vtree-vchevron {
+ opacity: 0.7;
+ }
+
+ /* Status dot (tenant level) */
+ .vtree-vdot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ /* Alert dot (namespace level) */
+ .vtree-valert-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: oklch(0.65 0.25 28);
+ }
+
+ /* Count badge */
+ .vtree-vbadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 0.3rem;
+ border-radius: 9999px;
+ font-size: 10px;
+ font-weight: 600;
+ background: color-mix(in oklab, var(--muted) 80%, transparent);
+ color: var(--muted-foreground);
+ line-height: 1;
+ }
+
+ /* Meta text */
+ .vtree-vmeta {
+ font-size: 11px;
+ color: var(--muted-foreground);
+ font-weight: 400;
+ }
+
+ /* State tag */
+ .vtree-vstate {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10px;
+ padding: 0.0625rem 0.4375rem;
+ border-radius: 9999px;
+ border-width: 1px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ }
+
+ /* ---- Connector lines ---- */
+
+ /* Children container: horizontal flex row */
+ .vtree-vchildren {
+ display: flex;
+ justify-content: center;
+ padding-top: 2rem;
+ position: relative;
+ }
+
+ /* Vertical stem from parent to horizontal bar */
+ .vtree-vchildren::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 1px;
+ height: 1rem;
+ background: color-mix(in oklab, var(--border) 75%, var(--primary) 25%);
+ }
+
+ /* Each child column */
+ .vtree-vchildren > .vtree-vnode {
+ padding: 1rem 0.75rem 0;
+ position: relative;
+ }
+
+ /* Horizontal bar segment across each child */
+ .vtree-vchildren > .vtree-vnode::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: color-mix(in oklab, var(--border) 75%, var(--primary) 25%);
+ }
+
+ /* Vertical stub from bar down to child box */
+ .vtree-vchildren > .vtree-vnode::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 1px;
+ height: 1rem;
+ background: color-mix(in oklab, var(--border) 75%, var(--primary) 25%);
+ }
+
+ /* First child: clip horizontal bar left half */
+ .vtree-vchildren > .vtree-vnode:first-child::before {
+ left: 50%;
+ }
+
+ /* Last child: clip horizontal bar right half */
+ .vtree-vchildren > .vtree-vnode:last-child::before {
+ right: 50%;
+ }
+
+ /* Only child: hide horizontal bar entirely */
+ .vtree-vchildren > .vtree-vnode:only-child::before {
+ display: none;
+ }
+
+ /* ---- Pod tiles (leaf level) ---- */
+
+ .vtree-vpods {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.3rem;
+ padding-top: 1rem;
+ position: relative;
+ max-width: 16rem;
+ }
+
+ /* Short vertical stem above pod tiles */
+ .vtree-vpods::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 1px;
+ height: 0.625rem;
+ background: color-mix(in oklab, var(--border) 75%, var(--primary) 25%);
+ }
+
+ /* Single tile */
+ .vtree-vtile {
+ width: 20px;
+ height: 20px;
+ border-radius: 0.25rem;
+ position: relative;
+ cursor: default;
+ transition:
+ transform 0.12s ease,
+ box-shadow 0.12s ease;
+ }
+
+ .vtree-vtile:hover {
+ transform: scale(1.3);
+ z-index: 20;
+ }
+
+ .vtree-vtile--ok {
+ background: oklch(0.72 0.17 155);
+ box-shadow: inset 0 1px 0 oklch(0.82 0.12 155 / 40%);
+ }
+
+ .vtree-vtile--warn {
+ background: oklch(0.78 0.16 80);
+ box-shadow: inset 0 1px 0 oklch(0.88 0.1 80 / 40%);
+ }
+
+ .vtree-vtile--error {
+ background: oklch(0.58 0.22 27);
+ box-shadow: inset 0 1px 0 oklch(0.7 0.15 27 / 40%);
+ }
+
+ .vtree-vtile:hover.vtree-vtile--ok {
+ box-shadow:
+ 0 0 10px 2px oklch(0.72 0.17 155 / 50%),
+ inset 0 1px 0 oklch(0.82 0.12 155 / 40%);
+ }
+
+ .vtree-vtile:hover.vtree-vtile--warn {
+ box-shadow:
+ 0 0 10px 2px oklch(0.78 0.16 80 / 50%),
+ inset 0 1px 0 oklch(0.88 0.1 80 / 40%);
+ }
+
+ .vtree-vtile:hover.vtree-vtile--error {
+ box-shadow:
+ 0 0 10px 2px oklch(0.58 0.22 27 / 50%),
+ inset 0 1px 0 oklch(0.7 0.15 27 / 40%);
+ }
+
+ /* Dark mode: brighter glow on dark background */
+ .dark .vtree-vtile:hover.vtree-vtile--ok {
+ box-shadow:
+ 0 0 14px 3px oklch(0.72 0.17 155 / 60%),
+ inset 0 1px 0 oklch(0.82 0.12 155 / 40%);
+ }
+
+ .dark .vtree-vtile:hover.vtree-vtile--warn {
+ box-shadow:
+ 0 0 14px 3px oklch(0.78 0.16 80 / 60%),
+ inset 0 1px 0 oklch(0.88 0.1 80 / 40%);
+ }
+
+ .dark .vtree-vtile:hover.vtree-vtile--error {
+ box-shadow:
+ 0 0 14px 3px oklch(0.58 0.22 27 / 60%),
+ inset 0 1px 0 oklch(0.7 0.15 27 / 40%);
+ }
+
+ /* ---- Tooltip popover ---- */
+
+ .vtree-vtile-tip {
+ display: none;
+ position: absolute;
+ bottom: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%);
+ min-width: 14rem;
+ padding: 0.625rem 0.75rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border);
+ background: var(--card);
+ box-shadow: 0 4px 16px color-mix(in oklab, var(--foreground) 10%, transparent);
+ pointer-events: none;
+ z-index: 50;
+ }
+
+ /* Arrow */
+ .vtree-vtile-tip::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 5px solid transparent;
+ border-top-color: var(--border);
+ }
+
+ .vtree-vtile:hover .vtree-vtile-tip {
+ display: block;
+ }
+
+ .vtree-vtile-tip-name {
+ font-size: 12px;
+ font-weight: 600;
+ font-family: var(--font-geist-mono);
+ margin-bottom: 0.375rem;
+ padding-bottom: 0.375rem;
+ border-bottom: 1px solid color-mix(in oklab, var(--border) 60%, transparent);
+ word-break: break-all;
+ }
+
+ .vtree-vtile-tip-rows {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.125rem 0.5rem;
+ font-size: 11px;
+ }
+
+ .vtree-vtile-tip-label {
+ color: var(--muted-foreground);
+ white-space: nowrap;
+ }
+
+ .vtree-vtile-tip-val {
+ font-weight: 500;
+ font-family: var(--font-geist-mono);
+ text-align: right;
+ }
+
+ .dark .vtree-vtile-tip {
+ box-shadow: 0 4px 20px color-mix(in oklab, black 30%, transparent);
+ }
+
+ .dark .vtree-vpods::before {
+ background: color-mix(in oklab, var(--border) 60%, white 40%);
+ }
+
+ /* ---- Dark mode connector overrides ---- */
+
+ .dark .vtree-vchildren::before,
+ .dark .vtree-vchildren > .vtree-vnode::before,
+ .dark .vtree-vchildren > .vtree-vnode::after,
+ .dark .vtree-vpods::before {
+ background: color-mix(in oklab, var(--border) 60%, white 40%);
+ }
+
+ .dark .vtree-vbox--cluster {
+ border-color: color-mix(in oklab, var(--primary) 35%, var(--border) 65%);
+ box-shadow: 0 1px 3px color-mix(in oklab, var(--primary) 8%, transparent);
+ }
+
+ .dark .vtree-vbox:hover {
+ box-shadow: 0 2px 8px color-mix(in oklab, white 4%, transparent);
+ border-color: color-mix(in oklab, var(--border) 40%, white 60%);
+ }
+}
+
@keyframes fadeInWord {
0% {
opacity: 0;
diff --git a/console-web/app/layout.tsx b/console-web/app/layout.tsx
index 6b2cb40..585c523 100755
--- a/console-web/app/layout.tsx
+++ b/console-web/app/layout.tsx
@@ -12,7 +12,7 @@ export const metadata: Metadata = {
}
// Inline script: set
to current origin (protocol + host + /) so relative URLs
-// (e.g. /tenants, /cluster) resolve to the same port as the page. Avoids prefetch/nav
+// (e.g. /tenants) resolve to the same port as the page. Avoids prefetch/nav
// going to port 80 when the app is actually served on e.g. port 8080 (port-forward).
const setBaseHrefInline = `(function(){var u=location;var b=document.createElement('base');b.href=u.protocol+'//'+u.host+'/';if(document.head.firstChild){document.head.insertBefore(b,document.head.firstChild);}else{document.head.appendChild(b);}})();`
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 (
{
+ if (typeof window === "undefined" || this.unauthorizedHandling) return
+
+ this.unauthorizedHandling = true
+ try {
+ await fetch(`${this.getBaseUrl()}/logout`, {
+ method: "POST",
+ credentials: "include",
+ })
+ } catch {
+ // ignore logout errors
+ }
+
+ if (window.location.pathname !== routes.login) {
+ window.location.assign(routes.login)
+ }
+ }
+
private async request(endpoint: string, options: RequestInit = {}): Promise {
const baseUrl = this.getBaseUrl()
const url = `${baseUrl}${endpoint}`
@@ -40,6 +61,10 @@ class ApiClient {
// ignore parse errors
}
+ if (response.status === 401) {
+ await this.handleUnauthorized()
+ }
+
throw error
}
@@ -55,6 +80,9 @@ class ApiClient {
})
if (!response.ok) {
const text = await response.text()
+ if (response.status === 401) {
+ await this.handleUnauthorized()
+ }
throw { message: text || response.statusText, statusCode: response.status }
}
return response.text()
diff --git a/console-web/lib/api.ts b/console-web/lib/api.ts
index c72560d..2443a03 100644
--- a/console-web/lib/api.ts
+++ b/console-web/lib/api.ts
@@ -17,11 +17,20 @@ import type {
NodeListResponse,
NamespaceListResponse,
ClusterResourcesResponse,
+ TenantYamlPayload,
+ TenantLifecycleState,
+ TenantStateCountsResponse,
+ EncryptionInfoResponse,
+ UpdateEncryptionRequest,
+ EncryptionUpdateResponse,
+ SecurityContextInfo,
+ UpdateSecurityContextRequest,
+ SecurityContextUpdateResponse,
} from "@/types/api"
+import type { TopologyOverviewResponse } from "@/types/topology"
const ns = (namespace: string) => `/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)}`
@@ -30,35 +39,40 @@ const pod = (namespace: string, name: string, podName: string) =>
`${pods(namespace, name)}/${encodeURIComponent(podName)}`
const events = (namespace: string, tenantName: string) =>
`${ns(namespace)}/tenants/${encodeURIComponent(tenantName)}/events`
+const tenantYaml = (namespace: string, name: string) => `${tenant(namespace, name)}/yaml`
+const tenantStateCounts = "/tenants/state-counts"
+const tenantStateCountsByNs = (namespace: string) => `${ns(namespace)}/tenants/state-counts`
// ----- Tenants -----
-export async function listTenants(): Promise {
- return apiClient.get("/tenants")
+export async function listTenants(params?: { state?: TenantLifecycleState }): Promise {
+ const search = new URLSearchParams()
+ if (params?.state) search.set("state", params.state)
+ const q = search.toString()
+ return apiClient.get(`/tenants${q ? `?${q}` : ""}`)
}
export async function listTenantsByNamespace(
- namespace: string
+ namespace: string,
+ params?: { state?: TenantLifecycleState },
): Promise {
- return apiClient.get(`${ns(namespace)}/tenants`)
+ const search = new URLSearchParams()
+ if (params?.state) search.set("state", params.state)
+ const q = search.toString()
+ return apiClient.get(`${ns(namespace)}/tenants${q ? `?${q}` : ""}`)
}
-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 +85,61 @@ 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(
+export async function getTenantYaml(namespace: string, name: string): Promise {
+ return apiClient.get(tenantYaml(namespace, name))
+}
+
+export async function updateTenantYaml(
namespace: string,
- tenantName: string
-): Promise {
+ name: string,
+ body: TenantYamlPayload,
+): Promise {
+ return apiClient.put(tenantYaml(namespace, name), body)
+}
+
+export async function listTenantStateCounts(): Promise {
+ return apiClient.get(tenantStateCounts)
+}
+
+export async function listTenantStateCountsByNamespace(namespace: string): Promise {
+ return apiClient.get(tenantStateCountsByNs(namespace))
+}
+
+// ----- Pools -----
+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 +150,48 @@ 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(
+// ----- Encryption -----
+const encryption = (namespace: string, name: string) => `${tenant(namespace, name)}/encryption`
+
+export async function getEncryption(namespace: string, name: string): Promise {
+ return apiClient.get(encryption(namespace, name))
+}
+
+export async function updateEncryption(
+ namespace: string,
+ name: string,
+ body: UpdateEncryptionRequest,
+): Promise {
+ return apiClient.put(encryption(namespace, name), body)
+}
+
+// ----- Security Context -----
+const securityContext = (namespace: string, name: string) => `${tenant(namespace, name)}/security-context`
+
+export async function getSecurityContext(namespace: string, name: string): Promise {
+ return apiClient.get(securityContext(namespace, name))
+}
+
+export async function updateSecurityContext(
namespace: string,
- tenantName: string
-): Promise {
+ name: string,
+ body: UpdateSecurityContextRequest,
+): Promise {
+ return apiClient.put(securityContext(namespace, name), body)
+}
+
+// ----- Events -----
+export async function listTenantEvents(namespace: string, tenantName: string): Promise {
return apiClient.get(events(namespace, tenantName))
}
@@ -181,3 +211,8 @@ export async function listNamespaces(): Promise {
export async function createNamespace(name: string): Promise {
return apiClient.post("/namespaces", { name })
}
+
+// ----- Topology -----
+export async function getTopologyOverview(): Promise {
+ return apiClient.get("/topology/overview")
+}
diff --git a/console-web/lib/mocks/topology.ts b/console-web/lib/mocks/topology.ts
new file mode 100644
index 0000000..6e7a2f3
--- /dev/null
+++ b/console-web/lib/mocks/topology.ts
@@ -0,0 +1,538 @@
+import type { TopologyOverviewResponse } from "@/types/topology"
+
+function pods(prefix: string, pool: string, count: number, node: string, failAt?: number[], pendingAt?: number[]) {
+ return Array.from({ length: count }, (_, i) => ({
+ name: `${prefix}-${pool}-${i}`,
+ pool,
+ phase: failAt?.includes(i)
+ ? ("Failed" as const)
+ : pendingAt?.includes(i)
+ ? ("Pending" as const)
+ : ("Running" as const),
+ ready: failAt?.includes(i) ? "0/1" : pendingAt?.includes(i) ? "0/1" : "1/1",
+ node: `${node}-${i % 4}`,
+ }))
+}
+
+export const topologyOverviewMock: TopologyOverviewResponse = {
+ cluster: {
+ id: "rustfs-cluster",
+ name: "RustFS Cluster",
+ version: "v0.0.1",
+ summary: {
+ nodes: 12,
+ namespaces: 6,
+ tenants: 18,
+ unhealthy_tenants: 5,
+ total_cpu: "96",
+ total_memory: "384Gi",
+ allocatable_cpu: "88",
+ allocatable_memory: "352Gi",
+ },
+ },
+ namespaces: [
+ {
+ name: "storage-prod",
+ tenant_count: 4,
+ unhealthy_tenant_count: 1,
+ tenants: [
+ {
+ name: "alpha",
+ namespace: "storage-prod",
+ state: "Ready",
+ created_at: "2026-03-01T02:30:00Z",
+ summary: {
+ pool_count: 3,
+ replicas: 24,
+ capacity: "96 TiB",
+ capacity_bytes: 105553116266496,
+ endpoint: "http://alpha.storage-prod.svc:9000",
+ console_endpoint: "http://alpha-console.storage-prod.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-1", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-2", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ ],
+ pods: [
+ ...pods("alpha", "pool-0", 8, "worker-a"),
+ ...pods("alpha", "pool-1", 8, "worker-b"),
+ ...pods("alpha", "pool-2", 8, "worker-c"),
+ ],
+ },
+ {
+ name: "beta",
+ namespace: "storage-prod",
+ state: "Degraded",
+ created_at: "2026-03-02T08:15:00Z",
+ summary: {
+ pool_count: 2,
+ replicas: 12,
+ capacity: "48 TiB",
+ capacity_bytes: 52776558133248,
+ endpoint: "http://beta.storage-prod.svc:9000",
+ console_endpoint: "http://beta-console.storage-prod.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Degraded", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "24 TiB" },
+ { name: "pool-1", state: "Ready", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "24 TiB" },
+ ],
+ pods: [...pods("beta", "pool-0", 6, "worker-d", [2, 5], [3]), ...pods("beta", "pool-1", 6, "worker-e")],
+ },
+ {
+ name: "gamma-archive",
+ namespace: "storage-prod",
+ state: "Ready",
+ created_at: "2026-02-20T12:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "16 TiB",
+ capacity_bytes: 17592186044416,
+ endpoint: "http://gamma-archive.storage-prod.svc:9000",
+ console_endpoint: "http://gamma-archive-console.storage-prod.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "16 TiB" },
+ ],
+ pods: pods("gamma-archive", "pool-0", 4, "worker-a"),
+ },
+ {
+ name: "delta-logs",
+ namespace: "storage-prod",
+ state: "Ready",
+ created_at: "2026-03-05T09:00:00Z",
+ summary: {
+ pool_count: 2,
+ replicas: 16,
+ capacity: "64 TiB",
+ capacity_bytes: 70368744177664,
+ endpoint: "http://delta-logs.storage-prod.svc:9000",
+ console_endpoint: "http://delta-logs-console.storage-prod.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-1", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ ],
+ pods: [...pods("delta-logs", "pool-0", 8, "worker-f"), ...pods("delta-logs", "pool-1", 8, "worker-g")],
+ },
+ ],
+ },
+ {
+ name: "storage-staging",
+ tenant_count: 3,
+ unhealthy_tenant_count: 1,
+ tenants: [
+ {
+ name: "staging-main",
+ namespace: "storage-staging",
+ state: "Ready",
+ created_at: "2026-03-06T14:00:00Z",
+ summary: {
+ pool_count: 2,
+ replicas: 8,
+ capacity: "32 TiB",
+ capacity_bytes: 35184372088832,
+ endpoint: "http://staging-main.storage-staging.svc:9000",
+ console_endpoint: "http://staging-main-console.storage-staging.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "16 TiB" },
+ { name: "pool-1", state: "Ready", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "16 TiB" },
+ ],
+ pods: [...pods("staging-main", "pool-0", 4, "worker-h"), ...pods("staging-main", "pool-1", 4, "worker-h")],
+ },
+ {
+ name: "staging-hotfix",
+ namespace: "storage-staging",
+ state: "Updating",
+ created_at: "2026-03-10T16:30:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 6,
+ capacity: "12 TiB",
+ capacity_bytes: 13194139533312,
+ endpoint: "http://staging-hotfix.storage-staging.svc:9000",
+ console_endpoint: "http://staging-hotfix-console.storage-staging.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Updating", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "12 TiB" },
+ ],
+ pods: pods("staging-hotfix", "pool-0", 6, "worker-i", [], [4, 5]),
+ },
+ {
+ name: "staging-perf",
+ namespace: "storage-staging",
+ state: "Ready",
+ created_at: "2026-03-07T11:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "8 TiB",
+ capacity_bytes: 8796093022208,
+ endpoint: "http://staging-perf.storage-staging.svc:9000",
+ console_endpoint: "http://staging-perf-console.storage-staging.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "8 TiB" },
+ ],
+ pods: pods("staging-perf", "pool-0", 4, "worker-j"),
+ },
+ ],
+ },
+ {
+ name: "storage-test",
+ tenant_count: 3,
+ unhealthy_tenant_count: 1,
+ tenants: [
+ {
+ name: "test-ci",
+ namespace: "storage-test",
+ state: "Ready",
+ created_at: "2026-03-09T03:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "4 TiB",
+ capacity_bytes: 4398046511104,
+ endpoint: "http://test-ci.storage-test.svc:9000",
+ console_endpoint: "http://test-ci-console.storage-test.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 4, volumes_per_server: 2, replicas: 4, capacity: "4 TiB" },
+ ],
+ pods: pods("test-ci", "pool-0", 4, "worker-k"),
+ },
+ {
+ name: "test-integration",
+ namespace: "storage-test",
+ state: "NotReady",
+ created_at: "2026-03-08T10:20:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "4 TiB",
+ capacity_bytes: 4398046511104,
+ endpoint: "http://test-integration.storage-test.svc:9000",
+ console_endpoint: "http://test-integration-console.storage-test.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "NotReady", servers: 4, volumes_per_server: 2, replicas: 4, capacity: "4 TiB" },
+ ],
+ pods: pods("test-integration", "pool-0", 4, "worker-k", [0, 1, 2, 3]),
+ },
+ {
+ name: "test-e2e",
+ namespace: "storage-test",
+ state: "Ready",
+ created_at: "2026-03-11T07:45:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 2,
+ capacity: "2 TiB",
+ capacity_bytes: 2199023255552,
+ endpoint: "http://test-e2e.storage-test.svc:9000",
+ console_endpoint: "http://test-e2e-console.storage-test.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 2, volumes_per_server: 4, replicas: 2, capacity: "2 TiB" },
+ ],
+ pods: pods("test-e2e", "pool-0", 2, "worker-l"),
+ },
+ ],
+ },
+ {
+ name: "analytics",
+ tenant_count: 3,
+ unhealthy_tenant_count: 1,
+ tenants: [
+ {
+ name: "analytics-datalake",
+ namespace: "analytics",
+ state: "Ready",
+ created_at: "2026-02-15T00:00:00Z",
+ summary: {
+ pool_count: 4,
+ replicas: 32,
+ capacity: "128 TiB",
+ capacity_bytes: 140737488355328,
+ endpoint: "http://analytics-datalake.analytics.svc:9000",
+ console_endpoint: "http://analytics-datalake-console.analytics.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-1", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-2", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ { name: "pool-3", state: "Ready", servers: 8, volumes_per_server: 4, replicas: 8, capacity: "32 TiB" },
+ ],
+ pods: [
+ ...pods("analytics-datalake", "pool-0", 8, "worker-a"),
+ ...pods("analytics-datalake", "pool-1", 8, "worker-b"),
+ ...pods("analytics-datalake", "pool-2", 8, "worker-c"),
+ ...pods("analytics-datalake", "pool-3", 8, "worker-d"),
+ ],
+ },
+ {
+ name: "analytics-realtime",
+ namespace: "analytics",
+ state: "Degraded",
+ created_at: "2026-03-01T06:00:00Z",
+ summary: {
+ pool_count: 2,
+ replicas: 12,
+ capacity: "24 TiB",
+ capacity_bytes: 26388279066624,
+ endpoint: "http://analytics-realtime.analytics.svc:9000",
+ console_endpoint: "http://analytics-realtime-console.analytics.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "12 TiB" },
+ { name: "pool-1", state: "Degraded", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "12 TiB" },
+ ],
+ pods: [
+ ...pods("analytics-realtime", "pool-0", 6, "worker-e"),
+ ...pods("analytics-realtime", "pool-1", 6, "worker-f", [1, 4], [5]),
+ ],
+ },
+ {
+ name: "analytics-metrics",
+ namespace: "analytics",
+ state: "Ready",
+ created_at: "2026-02-28T18:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "8 TiB",
+ capacity_bytes: 8796093022208,
+ endpoint: "http://analytics-metrics.analytics.svc:9000",
+ console_endpoint: "http://analytics-metrics-console.analytics.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "8 TiB" },
+ ],
+ pods: pods("analytics-metrics", "pool-0", 4, "worker-g"),
+ },
+ ],
+ },
+ {
+ name: "ml-platform",
+ tenant_count: 2,
+ unhealthy_tenant_count: 1,
+ tenants: [
+ {
+ name: "ml-datasets",
+ namespace: "ml-platform",
+ state: "Ready",
+ created_at: "2026-02-25T08:00:00Z",
+ summary: {
+ pool_count: 3,
+ replicas: 18,
+ capacity: "72 TiB",
+ capacity_bytes: 79164837199872,
+ endpoint: "http://ml-datasets.ml-platform.svc:9000",
+ console_endpoint: "http://ml-datasets-console.ml-platform.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "24 TiB" },
+ { name: "pool-1", state: "Ready", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "24 TiB" },
+ { name: "pool-2", state: "Ready", servers: 6, volumes_per_server: 4, replicas: 6, capacity: "24 TiB" },
+ ],
+ pods: [
+ ...pods("ml-datasets", "pool-0", 6, "worker-h"),
+ ...pods("ml-datasets", "pool-1", 6, "worker-i"),
+ ...pods("ml-datasets", "pool-2", 6, "worker-j"),
+ ],
+ },
+ {
+ name: "ml-checkpoints",
+ namespace: "ml-platform",
+ state: "Unknown",
+ created_at: "2026-03-10T22:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 4,
+ capacity: "16 TiB",
+ capacity_bytes: 17592186044416,
+ endpoint: "http://ml-checkpoints.ml-platform.svc:9000",
+ console_endpoint: "http://ml-checkpoints-console.ml-platform.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Unknown", servers: 4, volumes_per_server: 4, replicas: 4, capacity: "16 TiB" },
+ ],
+ pods: pods("ml-checkpoints", "pool-0", 4, "worker-k", [0, 1], [2]),
+ },
+ ],
+ },
+ {
+ name: "sandbox",
+ tenant_count: 3,
+ unhealthy_tenant_count: 0,
+ tenants: [
+ {
+ name: "sandbox-dev",
+ namespace: "sandbox",
+ state: "Ready",
+ created_at: "2026-03-12T01:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 2,
+ capacity: "2 TiB",
+ capacity_bytes: 2199023255552,
+ endpoint: "http://sandbox-dev.sandbox.svc:9000",
+ console_endpoint: "http://sandbox-dev-console.sandbox.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 2, volumes_per_server: 4, replicas: 2, capacity: "2 TiB" },
+ ],
+ pods: pods("sandbox-dev", "pool-0", 2, "worker-l"),
+ },
+ {
+ name: "sandbox-demo",
+ namespace: "sandbox",
+ state: "Ready",
+ created_at: "2026-03-11T15:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 2,
+ capacity: "1 TiB",
+ capacity_bytes: 1099511627776,
+ endpoint: "http://sandbox-demo.sandbox.svc:9000",
+ console_endpoint: "http://sandbox-demo-console.sandbox.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 2, volumes_per_server: 2, replicas: 2, capacity: "1 TiB" },
+ ],
+ pods: pods("sandbox-demo", "pool-0", 2, "worker-l"),
+ },
+ {
+ name: "sandbox-playground",
+ namespace: "sandbox",
+ state: "Ready",
+ created_at: "2026-03-13T00:00:00Z",
+ summary: {
+ pool_count: 1,
+ replicas: 2,
+ capacity: "1 TiB",
+ capacity_bytes: 1099511627776,
+ endpoint: "http://sandbox-playground.sandbox.svc:9000",
+ console_endpoint: "http://sandbox-playground-console.sandbox.svc:9001",
+ },
+ pools: [
+ { name: "pool-0", state: "Ready", servers: 2, volumes_per_server: 2, replicas: 2, capacity: "1 TiB" },
+ ],
+ pods: pods("sandbox-playground", "pool-0", 2, "worker-l"),
+ },
+ ],
+ },
+ ],
+ nodes: [
+ {
+ name: "worker-a-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "28Gi",
+ },
+ {
+ name: "worker-a-1",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "28Gi",
+ },
+ {
+ name: "worker-b-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-b-1",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-c-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "8",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-c-1",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "8",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-d-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "28Gi",
+ },
+ {
+ name: "worker-e-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "8",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-f-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "29Gi",
+ },
+ {
+ name: "worker-g-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "8",
+ memory_allocatable: "30Gi",
+ },
+ {
+ name: "worker-h-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "7",
+ memory_allocatable: "28Gi",
+ },
+ {
+ name: "worker-i-0",
+ status: "Ready",
+ roles: ["worker"],
+ cpu_capacity: "8",
+ memory_capacity: "32Gi",
+ cpu_allocatable: "8",
+ memory_allocatable: "30Gi",
+ },
+ ],
+}
diff --git a/console-web/lib/routes.ts b/console-web/lib/routes.ts
index 13367c4..5fd63f1 100755
--- a/console-web/lib/routes.ts
+++ b/console-web/lib/routes.ts
@@ -5,5 +5,4 @@ export const routes = {
tenantNew: "/tenants/new",
tenantDetail: (namespace: string, name: string) =>
`/tenants/detail?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(name)}`,
- cluster: "/cluster",
}
diff --git a/console-web/lib/utils.ts b/console-web/lib/utils.ts
index bd0c391..faec4b8 100755
--- a/console-web/lib/utils.ts
+++ b/console-web/lib/utils.ts
@@ -4,3 +4,52 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/**
+ * Parse a Kubernetes-style size string (e.g. "32883524Ki", "10Gi", "512Mi") to bytes.
+ * Returns null if the input is empty or unparseable.
+ */
+export function parseSizeToBytes(size: string | null | undefined): number | null {
+ if (!size) return null
+ const match = size.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgtpe]?i?b?)?$/i)
+ if (!match) return null
+
+ const value = Number.parseFloat(match[1] ?? "0")
+ if (!Number.isFinite(value) || value < 0) return null
+
+ const rawUnit = (match[2] ?? "").toUpperCase().replace(/B$/, "")
+ if (!rawUnit) return value
+
+ const binary = rawUnit.endsWith("I")
+ const unit = binary ? rawUnit.slice(0, -1) : rawUnit
+ const powers: Record = { "": 0, K: 1, M: 2, G: 3, T: 4, P: 5, E: 6 }
+ const power = powers[unit]
+ if (power == null) return null
+ const base = binary ? 1024 : 1000
+ return value * base ** power
+}
+
+/**
+ * Format a byte count into a human-readable binary string (TiB, GiB, MiB, B).
+ */
+export function formatBinaryBytes(bytes: number): string {
+ const format = (value: number) => {
+ if (Number.isInteger(value)) return String(value)
+ return value.toFixed(1).replace(/\.0$/, "")
+ }
+
+ if (bytes >= 1024 ** 4) return `${format(bytes / 1024 ** 4)} TiB`
+ if (bytes >= 1024 ** 3) return `${format(bytes / 1024 ** 3)} GiB`
+ if (bytes >= 1024 ** 2) return `${format(bytes / 1024 ** 2)} MiB`
+ return `${format(bytes)} B`
+}
+
+/**
+ * Parse a K8s size string and format it as a human-readable binary string.
+ * Returns the original string if parsing fails.
+ */
+export function formatK8sMemory(size: string): string {
+ const bytes = parseSizeToBytes(size)
+ if (bytes == null) return size
+ return formatBinaryBytes(bytes)
+}
diff --git a/console-web/package-lock.json b/console-web/package-lock.json
index 1d2f4cb..cbd4043 100644
--- a/console-web/package-lock.json
+++ b/console-web/package-lock.json
@@ -21,7 +21,8 @@
"react-dom": "19.2.3",
"react-i18next": "^16.5.4",
"sonner": "^2.0.7",
- "tailwind-merge": "^3.4.0"
+ "tailwind-merge": "^3.4.0",
+ "yaml": "^2.8.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -36,6 +37,9 @@
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
+ },
+ "engines": {
+ "node": ">=22"
}
},
"node_modules/@alloc/quick-lru": {
@@ -779,19 +783,6 @@
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
@@ -803,15 +794,15 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.21.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
- "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
- "minimatch": "^3.1.2"
+ "minimatch": "^3.1.5"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -844,9 +835,9 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
- "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -857,7 +848,7 @@
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
- "minimatch": "^3.1.3",
+ "minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -868,9 +859,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.39.3",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
- "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6013,25 +6004,25 @@
}
},
"node_modules/eslint": {
- "version": "9.39.3",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
- "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.21.1",
+ "@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
- "@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.39.3",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
@@ -6050,7 +6041,7 @@
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
+ "minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -6382,6 +6373,18 @@
}
},
"node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
@@ -6412,6 +6415,19 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -6793,9 +6809,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.4",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
- "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
+ "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -11830,6 +11846,20 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
diff --git a/console-web/package.json b/console-web/package.json
index 4d8bc27..dd6f585 100755
--- a/console-web/package.json
+++ b/console-web/package.json
@@ -3,11 +3,19 @@
"version": "0.1.0",
"private": true,
"scripts": {
+ "check:node": "node -e \"const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < 20) { console.error('Node.js >= 20 is required.'); process.exit(1); }\"",
+ "predev": "node -e \"const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < 20) { console.error('Node.js >= 20 is required.'); process.exit(1); }\"",
+ "prebuild": "node -e \"const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < 20) { console.error('Node.js >= 20 is required.'); process.exit(1); }\"",
+ "prestart": "node -e \"const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < 20) { console.error('Node.js >= 20 is required.'); process.exit(1); }\"",
+ "prelint": "node -e \"const major = Number(process.versions.node.split('.')[0]); if (!Number.isFinite(major) || major < 20) { console.error('Node.js >= 20 is required.'); process.exit(1); }\"",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
+ "engines": {
+ "node": ">=20"
+ },
"dependencies": {
"@remixicon/react": "^4.9.0",
"class-variance-authority": "^0.7.1",
@@ -22,7 +30,8 @@
"react-dom": "19.2.3",
"react-i18next": "^16.5.4",
"sonner": "^2.0.7",
- "tailwind-merge": "^3.4.0"
+ "tailwind-merge": "^3.4.0",
+ "yaml": "^2.8.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -37,5 +46,6 @@
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
- }
+ },
+ "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
}
diff --git a/console-web/pnpm-lock.yaml b/console-web/pnpm-lock.yaml
index e28a418..0ea81d7 100755
--- a/console-web/pnpm-lock.yaml
+++ b/console-web/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
+ yaml:
+ specifier: ^2.8.2
+ version: 2.8.2
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -65,13 +68,13 @@ importers:
version: 19.2.3(@types/react@19.2.13)
eslint:
specifier: ^9
- version: 9.39.2(jiti@2.6.1)
+ version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
- version: 16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ version: 16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
+ version: 10.1.8(eslint@9.39.4(jiti@2.6.1))
prettier:
specifier: ^3.8.1
version: 3.8.1
@@ -260,8 +263,8 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint/config-array@0.21.1':
- resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.4.2':
@@ -272,12 +275,12 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/eslintrc@3.3.3':
- resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+ '@eslint/eslintrc@3.3.5':
+ resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/js@9.39.2':
- resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
+ '@eslint/js@9.39.4':
+ resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7':
@@ -355,105 +358,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -577,28 +564,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@@ -1395,28 +1378,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1579,49 +1558,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1669,8 +1640,8 @@ packages:
ajv:
optional: true
- ajv@6.12.6:
- resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ajv@6.14.0:
+ resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
@@ -2180,8 +2151,8 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- eslint@9.39.2:
- resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@@ -2817,28 +2788,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2931,6 +2898,9 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3787,6 +3757,11 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yaml@2.8.2:
+ resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -4055,18 +4030,18 @@ snapshots:
tslib: 2.8.1
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.1':
+ '@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
- minimatch: 3.1.2
+ minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4078,21 +4053,21 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.3':
+ '@eslint/eslintrc@3.3.5':
dependencies:
- ajv: 6.12.6
+ ajv: 6.14.0
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
- minimatch: 3.1.2
+ minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
- '@eslint/js@9.39.2': {}
+ '@eslint/js@9.39.4': {}
'@eslint/object-schema@2.1.7': {}
@@ -5245,15 +5220,15 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
- '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
- '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -5261,14 +5236,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5291,13 +5266,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -5320,13 +5295,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5412,7 +5387,7 @@ snapshots:
optionalDependencies:
ajv: 8.17.1
- ajv@6.12.6:
+ ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
@@ -5886,18 +5861,18 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
+ eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.1.6
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
globals: 16.4.0
- typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ typescript-eslint: 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -5906,9 +5881,9 @@ snapshots:
- eslint-plugin-import-x
- supports-color
- eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
+ eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)):
dependencies:
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -5918,33 +5893,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
get-tsconfig: 4.13.6
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- eslint: 9.39.2(jiti@2.6.1)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -5953,9 +5928,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -5967,13 +5942,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -5983,7 +5958,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -5992,18 +5967,18 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.0
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -6011,7 +5986,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.2
- eslint: 9.39.2(jiti@2.6.1)
+ eslint: 9.39.4(jiti@2.6.1)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -6034,21 +6009,21 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
- eslint@9.39.2(jiti@2.6.1):
+ eslint@9.39.4(jiti@2.6.1):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.1
+ '@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.3
- '@eslint/js': 9.39.2
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
- ajv: 6.12.6
+ ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@@ -6067,7 +6042,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
- minimatch: 3.1.2
+ minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -6773,6 +6748,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.12
+
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -7671,13 +7650,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- eslint: 9.39.2(jiti@2.6.1)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -7838,6 +7817,8 @@ snapshots:
yallist@3.1.1: {}
+ yaml@2.8.2: {}
+
yargs-parser@21.1.1: {}
yargs@17.7.2:
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..e350a25 100644
--- a/console-web/types/api.ts
+++ b/console-web/types/api.ts
@@ -19,6 +19,21 @@ export interface TenantListResponse {
tenants: TenantListItem[]
}
+export type TenantLifecycleState = "Ready" | "Updating" | "Degraded" | "NotReady" | "Unknown"
+
+export interface TenantStateCountItem {
+ state: string
+ count: number
+}
+
+export type TenantStateCountsResponse =
+ | TenantStateCountItem[]
+ | Record
+ | {
+ state_counts?: TenantStateCountItem[]
+ counts?: TenantStateCountItem[]
+ }
+
export interface ServicePort {
name: string
port: number
@@ -50,6 +65,13 @@ export interface CreatePoolRequest {
storage_class?: string
}
+export interface CreateSecurityContextRequest {
+ runAsUser?: number
+ runAsGroup?: number
+ fsGroup?: number
+ runAsNonRoot?: boolean
+}
+
export interface CreateTenantRequest {
name: string
namespace: string
@@ -57,6 +79,7 @@ export interface CreateTenantRequest {
image?: string
mount_path?: string
creds_secret?: string
+ security_context?: CreateSecurityContextRequest
}
export interface UpdateTenantRequest {
@@ -84,6 +107,10 @@ export interface UpdateTenantResponse {
tenant: TenantListItem
}
+export interface TenantYamlPayload {
+ yaml: string
+}
+
// ----- Pool -----
export interface PoolDetails {
name: string
@@ -181,10 +208,7 @@ export interface ContainerStateTerminated {
finished_at?: string
}
-export type ContainerState =
- | ContainerStateRunning
- | ContainerStateWaiting
- | ContainerStateTerminated
+export type ContainerState = ContainerStateRunning | ContainerStateWaiting | ContainerStateTerminated
export interface ContainerInfo {
name: string
@@ -234,6 +258,86 @@ export interface EventListResponse {
events: EventItem[]
}
+// ----- Encryption -----
+export interface AppRoleInfo {
+ engine: string | null
+ retrySeconds: number | null
+}
+
+export interface VaultInfo {
+ endpoint: string
+ engine: string | null
+ namespace: string | null
+ prefix: string | null
+ authType: string | null
+ appRole: AppRoleInfo | null
+ tlsSkipVerify: boolean | null
+ customCertificates: boolean | null
+}
+
+export interface LocalKmsInfo {
+ keyDirectory: string | null
+ masterKeyId: string | null
+}
+
+export interface SecurityContextInfo {
+ runAsUser: number | null
+ runAsGroup: number | null
+ fsGroup: number | null
+ runAsNonRoot: boolean | null
+}
+
+export interface EncryptionInfoResponse {
+ enabled: boolean
+ backend: string
+ vault: VaultInfo | null
+ local: LocalKmsInfo | null
+ kmsSecretName: string | null
+ pingSeconds: number | null
+ securityContext: SecurityContextInfo | null
+}
+
+export interface UpdateEncryptionRequest {
+ enabled: boolean
+ backend?: string
+ vault?: {
+ endpoint: string
+ engine?: string
+ namespace?: string
+ prefix?: string
+ authType?: string
+ appRole?: {
+ engine?: string
+ retrySeconds?: number
+ }
+ tlsSkipVerify?: boolean
+ customCertificates?: boolean
+ }
+ local?: {
+ keyDirectory?: string
+ masterKeyId?: string
+ }
+ kmsSecretName?: string
+ pingSeconds?: number
+}
+
+export interface EncryptionUpdateResponse {
+ success: boolean
+ message: string
+}
+
+export interface UpdateSecurityContextRequest {
+ runAsUser?: number
+ runAsGroup?: number
+ fsGroup?: number
+ runAsNonRoot?: boolean
+}
+
+export interface SecurityContextUpdateResponse {
+ success: boolean
+ message: string
+}
+
// ----- Cluster -----
export interface NodeInfo {
name: string
diff --git a/console-web/types/topology.ts b/console-web/types/topology.ts
new file mode 100644
index 0000000..5533906
--- /dev/null
+++ b/console-web/types/topology.ts
@@ -0,0 +1,78 @@
+export type TopologyTenantState = "Ready" | "Updating" | "Degraded" | "NotReady" | "Unknown"
+
+export interface TopologyClusterSummary {
+ nodes: number
+ namespaces: number
+ tenants: number
+ unhealthy_tenants: number
+ total_cpu: string
+ total_memory: string
+ allocatable_cpu: string
+ allocatable_memory: string
+}
+
+export interface TopologyCluster {
+ id: string
+ name: string
+ version: string
+ summary: TopologyClusterSummary
+}
+
+export interface TopologyTenantSummary {
+ pool_count: number
+ replicas: number
+ capacity: string
+ capacity_bytes: number
+ endpoint: string | null
+ console_endpoint: string | null
+}
+
+export interface TopologyPool {
+ name: string
+ state: TopologyTenantState
+ servers: number
+ volumes_per_server: number
+ replicas: number
+ capacity: string
+}
+
+export interface TopologyPod {
+ name: string
+ pool: string
+ phase: string
+ ready: string
+ node: string | null
+}
+
+export interface TopologyTenant {
+ name: string
+ namespace: string
+ state: TopologyTenantState
+ created_at: string | null
+ summary: TopologyTenantSummary
+ pools?: TopologyPool[]
+ pods?: TopologyPod[]
+}
+
+export interface TopologyNamespace {
+ name: string
+ tenant_count: number
+ unhealthy_tenant_count: number
+ tenants: TopologyTenant[]
+}
+
+export interface TopologyNode {
+ name: string
+ status: string
+ roles: string[]
+ cpu_capacity: string
+ memory_capacity: string
+ cpu_allocatable: string
+ memory_allocatable: string
+}
+
+export interface TopologyOverviewResponse {
+ cluster: TopologyCluster
+ namespaces: TopologyNamespace[]
+ nodes: TopologyNode[]
+}
diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml
index 979ef94..d31c7db 100644
--- a/deploy/rustfs-operator/crds/tenant-crd.yaml
+++ b/deploy/rustfs-operator/crds/tenant-crd.yaml
@@ -45,6 +45,135 @@ spec:
required:
- name
type: object
+ encryption:
+ description: |-
+ Encryption / KMS configuration for server-side encryption.
+ When enabled, the operator injects KMS environment variables and mounts
+ secrets into RustFS pods so the in-process `rustfs-kms` library is configured.
+ nullable: true
+ properties:
+ backend:
+ default: local
+ description: 'KMS backend type: `local` or `vault`.'
+ enum:
+ - local
+ - vault
+ type: string
+ enabled:
+ default: false
+ description: Enable server-side encryption. When `false`, all other fields are ignored.
+ type: boolean
+ kmsSecret:
+ description: |-
+ Reference to a Secret containing sensitive KMS credentials
+ (Vault token or AppRole credentials, TLS certificates).
+ nullable: true
+ properties:
+ name:
+ description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names'
+ type: string
+ required:
+ - name
+ type: object
+ local:
+ description: 'Local file-based settings (optional when `backend: local`).'
+ nullable: true
+ properties:
+ keyDirectory:
+ description: 'Directory for key files inside the container (default: `/data/kms-keys`).'
+ nullable: true
+ type: string
+ masterKeyId:
+ description: 'Master key identifier (default: `default-master-key`).'
+ nullable: true
+ type: string
+ type: object
+ pingSeconds:
+ description: |-
+ Interval in seconds for KMS health-check pings (default: disabled).
+ When set, the operator stores the value; the in-process KMS library
+ picks it up from `RUSTFS_KMS_PING_SECONDS`.
+ format: int32
+ nullable: true
+ type: integer
+ securityContext:
+ description: |-
+ Override Pod SecurityContext when encryption is enabled.
+ If not set, the default RustFS Pod SecurityContext is used
+ (runAsUser/runAsGroup/fsGroup = 10001).
+ nullable: true
+ properties:
+ fsGroup:
+ description: GID applied to all volumes mounted in the Pod.
+ format: int64
+ nullable: true
+ type: integer
+ runAsGroup:
+ description: GID to run the container process as.
+ format: int64
+ nullable: true
+ type: integer
+ runAsNonRoot:
+ description: 'Enforce non-root execution (default: true).'
+ nullable: true
+ type: boolean
+ runAsUser:
+ description: UID to run the container process as.
+ format: int64
+ nullable: true
+ type: integer
+ type: object
+ vault:
+ description: 'Vault-specific settings (required when `backend: vault`).'
+ nullable: true
+ properties:
+ appRole:
+ description: |-
+ AppRole authentication settings. Only used when `authType: approle`.
+ The actual `role_id` and `secret_id` values live in the KMS Secret
+ under keys `vault-approle-id` and `vault-approle-secret`.
+ nullable: true
+ properties:
+ engine:
+ description: Engine mount path for AppRole auth (e.g. `approle`).
+ nullable: true
+ type: string
+ retrySeconds:
+ description: 'Retry interval in seconds for AppRole login attempts (default: 10).'
+ format: int32
+ nullable: true
+ type: integer
+ type: object
+ authType:
+ default: token
+ description: |-
+ Authentication method: `token` (default, implemented) or `approle`
+ (type defined in rustfs-kms but backend not yet functional).
+ nullable: true
+ type: string
+ endpoint:
+ description: Vault server endpoint (e.g. `https://vault.example.com:8200`).
+ type: string
+ engine:
+ description: 'Vault KV2 engine mount path (default: `kv`).'
+ nullable: true
+ type: string
+ namespace:
+ description: Vault namespace (Enterprise feature).
+ nullable: true
+ type: string
+ prefix:
+ description: Key prefix inside the engine.
+ nullable: true
+ type: string
+ tlsSkipVerify:
+ description: 'Enable TLS verification for Vault connection (default: true).'
+ nullable: true
+ type: boolean
+ required:
+ - endpoint
+ type: object
+ type: object
env:
items:
description: EnvVar represents an environment variable present in a Container.
diff --git a/src/console/handlers/cluster.rs b/src/console/handlers/cluster.rs
index 4486112..6fb3fa7 100755
--- a/src/console/handlers/cluster.rs
+++ b/src/console/handlers/cluster.rs
@@ -250,7 +250,7 @@ pub async fn get_cluster_resources(
/// 将 Kubernetes CPU Quantity 解析为毫核 (millicores)。
/// 支持 "1"(核)、"1000m"、"500m" 等格式。
-fn parse_cpu_to_millicores(s: &str) -> i64 {
+pub(crate) fn parse_cpu_to_millicores(s: &str) -> i64 {
let s = s.trim();
if s.is_empty() {
return 0;
@@ -277,7 +277,7 @@ fn parse_cpu_to_millicores(s: &str) -> i64 {
}
/// 将毫核格式化为 CPU 字符串(如 "8" 或 "500m")。
-fn format_cpu_from_millicores(m: i64) -> String {
+pub(crate) fn format_cpu_from_millicores(m: i64) -> String {
if m == 0 {
return "0".to_string();
}
@@ -290,7 +290,7 @@ fn format_cpu_from_millicores(m: i64) -> String {
/// 将 Kubernetes Memory Quantity 解析为字节。
/// 支持 "1Gi"、"1G"、"1024Mi"、"1Ki" 等格式。
-fn parse_memory_to_bytes(s: &str) -> i64 {
+pub(crate) fn parse_memory_to_bytes(s: &str) -> i64 {
let s = s.trim();
if s.is_empty() {
return 0;
@@ -327,7 +327,7 @@ fn parse_memory_to_bytes(s: &str) -> i64 {
}
/// 将字节格式化为可读内存字符串(优先 Gi)。
-fn format_memory_from_bytes(b: i64) -> String {
+pub(crate) fn format_memory_from_bytes(b: i64) -> String {
const GIB: i64 = 1024 * 1024 * 1024;
const MIB: i64 = 1024 * 1024;
const KIB: i64 = 1024;
diff --git a/src/console/handlers/encryption.rs b/src/console/handlers/encryption.rs
new file mode 100644
index 0000000..52d1d0e
--- /dev/null
+++ b/src/console/handlers/encryption.rs
@@ -0,0 +1,219 @@
+// Copyright 2025 RustFS Team
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use crate::console::{
+ error::{self, Error, Result},
+ models::encryption::*,
+ state::Claims,
+};
+use crate::types::v1alpha1::encryption::{
+ EncryptionConfig, KmsBackendType, LocalKmsConfig, VaultAppRoleConfig, VaultAuthType,
+ VaultKmsConfig,
+};
+use crate::types::v1alpha1::tenant::Tenant;
+use axum::{Extension, Json, extract::Path};
+use k8s_openapi::api::core::v1 as corev1;
+use kube::api::{Patch, PatchParams};
+use kube::{Api, Client};
+
+/// GET /namespaces/:namespace/tenants/:name/encryption
+pub async fn get_encryption(
+ Path((namespace, name)): Path<(String, String)>,
+ Extension(claims): Extension,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::namespaced(client, &namespace);
+
+ let tenant = api
+ .get(&name)
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ let enc_resp =
+ match tenant.spec.encryption {
+ Some(ref enc) => EncryptionInfoResponse {
+ enabled: enc.enabled,
+ backend: enc.backend.to_string(),
+ vault: enc.vault.as_ref().map(|v| VaultInfo {
+ endpoint: v.endpoint.clone(),
+ engine: v.engine.clone(),
+ namespace: v.namespace.clone(),
+ prefix: v.prefix.clone(),
+ auth_type: v.auth_type.as_ref().map(|a| a.to_string()),
+ app_role: v.app_role.as_ref().map(|ar| AppRoleInfo {
+ engine: ar.engine.clone(),
+ retry_seconds: ar.retry_seconds,
+ }),
+ tls_skip_verify: v.tls_skip_verify,
+ custom_certificates: v.custom_certificates,
+ }),
+ local: enc.local.as_ref().map(|l| LocalInfo {
+ key_directory: l.key_directory.clone(),
+ master_key_id: l.master_key_id.clone(),
+ }),
+ kms_secret_name: enc.kms_secret.as_ref().map(|s| s.name.clone()),
+ ping_seconds: enc.ping_seconds,
+ security_context: tenant.spec.security_context.as_ref().map(|sc| {
+ SecurityContextInfo {
+ run_as_user: sc.run_as_user,
+ run_as_group: sc.run_as_group,
+ fs_group: sc.fs_group,
+ run_as_non_root: sc.run_as_non_root,
+ }
+ }),
+ },
+ None => EncryptionInfoResponse {
+ enabled: false,
+ backend: "local".to_string(),
+ vault: None,
+ local: None,
+ kms_secret_name: None,
+ ping_seconds: None,
+ security_context: tenant.spec.security_context.as_ref().map(|sc| {
+ SecurityContextInfo {
+ run_as_user: sc.run_as_user,
+ run_as_group: sc.run_as_group,
+ fs_group: sc.fs_group,
+ run_as_non_root: sc.run_as_non_root,
+ }
+ }),
+ },
+ };
+
+ Ok(Json(enc_resp))
+}
+
+/// PUT /namespaces/:namespace/tenants/:name/encryption
+pub async fn update_encryption(
+ Path((namespace, name)): Path<(String, String)>,
+ Extension(claims): Extension,
+ Json(body): Json,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::namespaced(client, &namespace);
+
+ let _tenant = api
+ .get(&name)
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ let encryption = if body.enabled {
+ let backend = match body.backend.as_deref() {
+ Some("vault") => KmsBackendType::Vault,
+ _ => KmsBackendType::Local,
+ };
+
+ // Validate Vault config when backend is Vault (fail fast with 400 instead of invalid spec)
+ if backend == KmsBackendType::Vault {
+ let vault_ok = body
+ .vault
+ .as_ref()
+ .map(|v| !v.endpoint.is_empty())
+ .unwrap_or(false);
+ if !vault_ok {
+ return Err(Error::BadRequest {
+ message: "Vault backend requires vault.endpoint to be non-empty".to_string(),
+ });
+ }
+ let secret_ok = body
+ .kms_secret_name
+ .as_ref()
+ .map(|s| !s.is_empty())
+ .unwrap_or(false);
+ if !secret_ok {
+ return Err(Error::BadRequest {
+ message: "Vault backend requires kmsSecretName".to_string(),
+ });
+ }
+ }
+
+ let vault = if backend == KmsBackendType::Vault {
+ body.vault.map(|v| VaultKmsConfig {
+ endpoint: v.endpoint,
+ engine: v.engine,
+ namespace: v.namespace,
+ prefix: v.prefix,
+ auth_type: v.auth_type.map(|s| match s.as_str() {
+ "approle" => VaultAuthType::Approle,
+ _ => VaultAuthType::Token,
+ }),
+ app_role: v.app_role.map(|ar| VaultAppRoleConfig {
+ engine: ar.engine,
+ retry_seconds: ar.retry_seconds,
+ }),
+ tls_skip_verify: v.tls_skip_verify,
+ custom_certificates: v.custom_certificates,
+ })
+ } else {
+ None
+ };
+
+ let local = if backend == KmsBackendType::Local {
+ body.local.map(|l| LocalKmsConfig {
+ key_directory: l.key_directory,
+ master_key_id: l.master_key_id,
+ })
+ } else {
+ None
+ };
+
+ let kms_secret = body
+ .kms_secret_name
+ .filter(|s| !s.is_empty())
+ .map(|s| corev1::LocalObjectReference { name: s });
+
+ Some(EncryptionConfig {
+ enabled: true,
+ backend,
+ vault,
+ local,
+ kms_secret,
+ ping_seconds: body.ping_seconds,
+ })
+ } else {
+ Some(EncryptionConfig {
+ enabled: false,
+ ..Default::default()
+ })
+ };
+
+ let patch = serde_json::json!({ "spec": { "encryption": encryption } });
+
+ api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ Ok(Json(EncryptionUpdateResponse {
+ success: true,
+ message: if body.enabled {
+ "Encryption configuration updated".to_string()
+ } else {
+ "Encryption disabled".to_string()
+ },
+ }))
+}
+
+async fn create_client(claims: &Claims) -> Result {
+ let mut config = kube::Config::infer()
+ .await
+ .map_err(|e| Error::InternalServer {
+ message: format!("Failed to load kubeconfig: {}", e),
+ })?;
+
+ config.auth_info.token = Some(claims.k8s_token.clone().into());
+
+ Client::try_from(config).map_err(|e| Error::InternalServer {
+ message: format!("Failed to create K8s client: {}", e),
+ })
+}
diff --git a/src/console/handlers/mod.rs b/src/console/handlers/mod.rs
index db4cc76..a31d144 100755
--- a/src/console/handlers/mod.rs
+++ b/src/console/handlers/mod.rs
@@ -14,7 +14,10 @@
pub mod auth;
pub mod cluster;
+pub mod encryption;
pub mod events;
pub mod pods;
pub mod pools;
+pub mod security_context;
pub mod tenants;
+pub mod topology;
diff --git a/src/console/handlers/security_context.rs b/src/console/handlers/security_context.rs
new file mode 100644
index 0000000..e03020b
--- /dev/null
+++ b/src/console/handlers/security_context.rs
@@ -0,0 +1,112 @@
+// Copyright 2025 RustFS Team
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use crate::console::{
+ error::{self, Error, Result},
+ models::encryption::{SecurityContextInfo, UpdateSecurityContextRequest},
+ state::Claims,
+};
+use crate::types::v1alpha1::encryption::PodSecurityContextOverride;
+use crate::types::v1alpha1::tenant::Tenant;
+use axum::{Extension, Json, extract::Path};
+use kube::api::{Patch, PatchParams};
+use kube::{Api, Client};
+
+/// GET /namespaces/:namespace/tenants/:name/security-context
+pub async fn get_security_context(
+ Path((namespace, name)): Path<(String, String)>,
+ Extension(claims): Extension,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::namespaced(client, &namespace);
+
+ let tenant = api
+ .get(&name)
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ let info = tenant.spec.security_context.as_ref().map_or_else(
+ || SecurityContextInfo {
+ run_as_user: None,
+ run_as_group: None,
+ fs_group: None,
+ run_as_non_root: None,
+ },
+ |sc| SecurityContextInfo {
+ run_as_user: sc.run_as_user,
+ run_as_group: sc.run_as_group,
+ fs_group: sc.fs_group,
+ run_as_non_root: sc.run_as_non_root,
+ },
+ );
+
+ Ok(Json(info))
+}
+
+/// PUT /namespaces/:namespace/tenants/:name/security-context
+pub async fn update_security_context(
+ Path((namespace, name)): Path<(String, String)>,
+ Extension(claims): Extension,
+ Json(body): Json,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::namespaced(client, &namespace);
+
+ let _tenant = api
+ .get(&name)
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ let security_context = PodSecurityContextOverride {
+ run_as_user: body.run_as_user,
+ run_as_group: body.run_as_group,
+ fs_group: body.fs_group,
+ run_as_non_root: body.run_as_non_root,
+ };
+
+ let patch = serde_json::json!({
+ "spec": {
+ "securityContext": serde_json::to_value(&security_context).map_err(|e| Error::Json { source: e })?
+ }
+ });
+
+ api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
+ .await
+ .map_err(|e| error::map_kube_error(e, format!("Tenant '{}'", name)))?;
+
+ Ok(Json(SecurityContextUpdateResponse {
+ success: true,
+ message: "SecurityContext updated".to_string(),
+ }))
+}
+
+#[derive(Debug, serde::Serialize)]
+pub struct SecurityContextUpdateResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+async fn create_client(claims: &Claims) -> Result {
+ let mut config = kube::Config::infer()
+ .await
+ .map_err(|e| Error::InternalServer {
+ message: format!("Failed to load kubeconfig: {}", e),
+ })?;
+
+ config.auth_info.token = Some(claims.k8s_token.clone().into());
+
+ Client::try_from(config).map_err(|e| Error::InternalServer {
+ message: format!("Failed to create K8s client: {}", e),
+ })
+}
diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs
index 2454368..9fe40d8 100755
--- a/src/console/handlers/tenants.rs
+++ b/src/console/handlers/tenants.rs
@@ -17,8 +17,14 @@ use crate::console::{
models::tenant::*,
state::Claims,
};
-use crate::types::v1alpha1::{persistence::PersistenceConfig, pool::Pool, tenant::Tenant};
-use axum::{Extension, Json, extract::Path};
+use crate::types::v1alpha1::{
+ encryption::PodSecurityContextOverride, persistence::PersistenceConfig, pool::Pool,
+ tenant::Tenant,
+};
+use axum::{
+ Extension, Json,
+ extract::{Path, Query},
+};
use k8s_openapi::api::core::v1 as corev1;
use kube::{Api, Client, ResourceExt, api::ListParams};
@@ -29,6 +35,7 @@ use kube::{Api, Client, ResourceExt, api::ListParams};
// curl -b cookies.txt http://localhost:9090/api/v1/tenants
pub async fn list_all_tenants(
+ Query(query): Query,
Extension(claims): Extension,
) -> Result> {
let client = create_client(&claims).await?;
@@ -39,30 +46,7 @@ pub async fn list_all_tenants(
.await
.map_err(|e| error::map_kube_error(e, "Tenants"))?;
- let items: Vec = tenants
- .items
- .into_iter()
- .map(|t| TenantListItem {
- name: t.name_any(),
- namespace: t.namespace().unwrap_or_default(),
- pools: t
- .spec
- .pools
- .iter()
- .map(|p| PoolInfo {
- name: p.name.clone(),
- servers: p.servers,
- volumes_per_server: p.persistence.volumes_per_server,
- })
- .collect(),
- state: t
- .status
- .as_ref()
- .map(|s| s.current_state.to_string())
- .unwrap_or_else(|| "Unknown".to_string()),
- created_at: t.metadata.creation_timestamp.map(|ts| ts.0.to_rfc3339()),
- })
- .collect();
+ let items = build_tenant_list_items(tenants.items, query.state.as_deref());
Ok(Json(TenantListResponse { tenants: items }))
}
@@ -70,6 +54,7 @@ pub async fn list_all_tenants(
/// 按命名空间列出 Tenants
pub async fn list_tenants_by_namespace(
Path(namespace): Path,
+ Query(query): Query,
Extension(claims): Extension,
) -> Result> {
let client = create_client(&claims).await?;
@@ -80,34 +65,42 @@ pub async fn list_tenants_by_namespace(
.await
.map_err(|e| error::map_kube_error(e, "Tenants"))?;
- let items: Vec = tenants
- .items
- .into_iter()
- .map(|t| TenantListItem {
- name: t.name_any(),
- namespace: t.namespace().unwrap_or_default(),
- pools: t
- .spec
- .pools
- .iter()
- .map(|p| PoolInfo {
- name: p.name.clone(),
- servers: p.servers,
- volumes_per_server: p.persistence.volumes_per_server,
- })
- .collect(),
- state: t
- .status
- .as_ref()
- .map(|s| s.current_state.to_string())
- .unwrap_or_else(|| "Unknown".to_string()),
- created_at: t.metadata.creation_timestamp.map(|ts| ts.0.to_rfc3339()),
- })
- .collect();
+ let items = build_tenant_list_items(tenants.items, query.state.as_deref());
Ok(Json(TenantListResponse { tenants: items }))
}
+/// 统计所有命名空间中 Tenant 的状态数量
+pub async fn get_all_tenant_state_counts(
+ Extension(claims): Extension,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::all(client);
+
+ let tenants = api
+ .list(&ListParams::default())
+ .await
+ .map_err(|e| error::map_kube_error(e, "Tenants"))?;
+
+ Ok(Json(summarize_tenant_states(&tenants.items)))
+}
+
+/// 统计指定命名空间中 Tenant 的状态数量
+pub async fn get_tenant_state_counts_by_namespace(
+ Path(namespace): Path,
+ Extension(claims): Extension,
+) -> Result> {
+ let client = create_client(&claims).await?;
+ let api: Api = Api::namespaced(client, &namespace);
+
+ let tenants = api
+ .list(&ListParams::default())
+ .await
+ .map_err(|e| error::map_kube_error(e, "Tenants"))?;
+
+ Ok(Json(summarize_tenant_states(&tenants.items)))
+}
+
/// 获取 Tenant 详情
pub async fn get_tenant_details(
Path((namespace, name)): Path<(String, String)>,
@@ -256,6 +249,16 @@ pub async fn create_tenant(
})
.collect();
+ let security_context = req
+ .security_context
+ .as_ref()
+ .map(|sc| PodSecurityContextOverride {
+ run_as_user: sc.run_as_user,
+ run_as_group: sc.run_as_group,
+ fs_group: sc.fs_group,
+ run_as_non_root: sc.run_as_non_root,
+ });
+
let tenant = Tenant {
metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
name: Some(req.name.clone()),
@@ -269,6 +272,7 @@ pub async fn create_tenant(
creds_secret: req
.creds_secret
.map(|name| corev1::LocalObjectReference { name }),
+ security_context,
..Default::default()
},
status: None,
@@ -473,6 +477,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()
@@ -487,3 +598,67 @@ async fn create_client(claims: &Claims) -> Result {
message: format!("Failed to create K8s client: {}", e),
})
}
+
+fn build_tenant_list_items(
+ tenants: Vec,
+ state_filter: Option<&str>,
+) -> Vec {
+ tenants
+ .into_iter()
+ .filter_map(|t| {
+ let item = tenant_to_list_item(t);
+ if state_matches_filter(&item.state, state_filter) {
+ Some(item)
+ } else {
+ None
+ }
+ })
+ .collect()
+}
+
+fn tenant_to_list_item(t: Tenant) -> TenantListItem {
+ let state = tenant_state(&t);
+ TenantListItem {
+ name: t.name_any(),
+ namespace: t.namespace().unwrap_or_default(),
+ pools: t
+ .spec
+ .pools
+ .iter()
+ .map(|p| PoolInfo {
+ name: p.name.clone(),
+ servers: p.servers,
+ volumes_per_server: p.persistence.volumes_per_server,
+ })
+ .collect(),
+ state,
+ created_at: t.metadata.creation_timestamp.map(|ts| ts.0.to_rfc3339()),
+ }
+}
+
+fn tenant_state(t: &Tenant) -> String {
+ t.status
+ .as_ref()
+ .map(|s| s.current_state.to_string())
+ .unwrap_or_else(|| "Unknown".to_string())
+}
+
+fn state_matches_filter(state: &str, state_filter: Option<&str>) -> bool {
+ match state_filter {
+ Some(filter) => state.eq_ignore_ascii_case(filter),
+ None => true,
+ }
+}
+
+fn summarize_tenant_states(tenants: &[Tenant]) -> TenantStateCountsResponse {
+ let mut counts = std::collections::BTreeMap::new();
+ for tenant in tenants {
+ let state = tenant_state(tenant);
+ *counts.entry(state).or_insert(0) += 1;
+ }
+
+ TenantStateCountsResponse {
+ total: tenants.len() as u32,
+ counts,
+ }
+}
diff --git a/src/console/handlers/topology.rs b/src/console/handlers/topology.rs
new file mode 100644
index 0000000..fa55361
--- /dev/null
+++ b/src/console/handlers/topology.rs
@@ -0,0 +1,407 @@
+// Copyright 2025 RustFS Team
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use crate::console::{
+ error::{self, Error, Result},
+ handlers::cluster::{
+ format_cpu_from_millicores, format_memory_from_bytes, parse_cpu_to_millicores,
+ parse_memory_to_bytes,
+ },
+ models::topology::*,
+ state::Claims,
+};
+use crate::types::v1alpha1::{status::pool::PoolState, tenant::Tenant};
+use axum::{Extension, Json};
+use k8s_openapi::api::core::v1 as corev1;
+use kube::{Api, Client, ResourceExt, api::ListParams};
+use std::collections::BTreeMap;
+
+/// 获取集群拓扑总览
+pub async fn get_topology_overview(
+ Extension(claims): Extension,
+) -> Result> {
+ let client = create_client(&claims).await?;
+
+ // 并行获取 nodes, tenants, pods
+ let node_api: Api = Api::all(client.clone());
+ let tenant_api: Api = Api::all(client.clone());
+ let pod_api: Api = Api::all(client.clone());
+
+ let node_params = ListParams::default();
+ let tenant_params = ListParams::default();
+ let pod_params = ListParams::default().labels("rustfs.tenant");
+
+ let (nodes_result, tenants_result, pods_result) = tokio::join!(
+ node_api.list(&node_params),
+ tenant_api.list(&tenant_params),
+ pod_api.list(&pod_params),
+ );
+
+ let k8s_nodes = nodes_result.map_err(|e| error::map_kube_error(e, "Nodes"))?;
+ let k8s_tenants = tenants_result.map_err(|e| error::map_kube_error(e, "Tenants"))?;
+ let k8s_pods = pods_result.map_err(|e| error::map_kube_error(e, "Pods"))?;
+
+ // 构建节点列表 + 集群资源汇总
+ let mut total_cpu_m: i64 = 0;
+ let mut total_mem_b: i64 = 0;
+ let mut alloc_cpu_m: i64 = 0;
+ let mut alloc_mem_b: i64 = 0;
+
+ let nodes: Vec = k8s_nodes
+ .items
+ .iter()
+ .map(|node| {
+ let status = node
+ .status
+ .as_ref()
+ .and_then(|s| {
+ s.conditions.as_ref().and_then(|conds| {
+ conds.iter().find(|c| c.type_ == "Ready").map(|c| {
+ if c.status == "True" {
+ "Ready"
+ } else {
+ "NotReady"
+ }
+ })
+ })
+ })
+ .unwrap_or("Unknown")
+ .to_string();
+
+ let roles: Vec = node
+ .metadata
+ .labels
+ .as_ref()
+ .map(|labels| {
+ labels
+ .iter()
+ .filter_map(|(k, _)| {
+ k.strip_prefix("node-role.kubernetes.io/")
+ .map(|r| r.to_string())
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let (cpu_cap, mem_cap, cpu_alloc, mem_alloc) = node
+ .status
+ .as_ref()
+ .map(|s| {
+ (
+ s.capacity
+ .as_ref()
+ .and_then(|c| c.get("cpu"))
+ .map(|q| q.0.clone())
+ .unwrap_or_default(),
+ s.capacity
+ .as_ref()
+ .and_then(|c| c.get("memory"))
+ .map(|q| q.0.clone())
+ .unwrap_or_default(),
+ s.allocatable
+ .as_ref()
+ .and_then(|a| a.get("cpu"))
+ .map(|q| q.0.clone())
+ .unwrap_or_default(),
+ s.allocatable
+ .as_ref()
+ .and_then(|a| a.get("memory"))
+ .map(|q| q.0.clone())
+ .unwrap_or_default(),
+ )
+ })
+ .unwrap_or_default();
+
+ // 累加集群资源
+ total_cpu_m += parse_cpu_to_millicores(&cpu_cap);
+ total_mem_b += parse_memory_to_bytes(&mem_cap);
+ alloc_cpu_m += parse_cpu_to_millicores(&cpu_alloc);
+ alloc_mem_b += parse_memory_to_bytes(&mem_alloc);
+
+ TopologyNode {
+ name: node.name_any(),
+ status,
+ roles,
+ cpu_capacity: cpu_cap,
+ memory_capacity: mem_cap,
+ cpu_allocatable: cpu_alloc,
+ memory_allocatable: mem_alloc,
+ }
+ })
+ .collect();
+
+ // 按 (namespace, tenant_name) 索引 pods
+ let mut pod_index: BTreeMap<(String, String), Vec> = BTreeMap::new();
+ for pod in &k8s_pods.items {
+ let labels = pod.metadata.labels.as_ref();
+ let tenant_name = labels
+ .and_then(|l| l.get("rustfs.tenant"))
+ .cloned()
+ .unwrap_or_default();
+ let pool = labels
+ .and_then(|l| l.get("rustfs.pool"))
+ .cloned()
+ .unwrap_or_else(|| "unknown".to_string());
+ let ns = pod.namespace().unwrap_or_default();
+
+ let phase = pod
+ .status
+ .as_ref()
+ .and_then(|s| s.phase.clone())
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ let (ready_count, total_count) = pod
+ .status
+ .as_ref()
+ .and_then(|s| s.container_statuses.as_ref())
+ .map(|cs| (cs.iter().filter(|c| c.ready).count(), cs.len()))
+ .unwrap_or((0, 0));
+
+ let node = pod.spec.as_ref().and_then(|s| s.node_name.clone());
+
+ let key = (ns, tenant_name);
+ pod_index.entry(key).or_default().push(TopologyPod {
+ name: pod.name_any(),
+ pool,
+ phase,
+ ready: format!("{}/{}", ready_count, total_count),
+ node,
+ });
+ }
+
+ // 按 namespace 分组 tenants
+ let mut ns_map: BTreeMap> = BTreeMap::new();
+ for t in &k8s_tenants.items {
+ let ns = t.namespace().unwrap_or_default();
+ ns_map.entry(ns).or_default().push(t);
+ }
+
+ let mut total_unhealthy: usize = 0;
+
+ let namespaces: Vec = ns_map
+ .into_iter()
+ .map(|(ns_name, tenants)| {
+ let mut unhealthy_count: usize = 0;
+
+ let tenant_items: Vec = tenants
+ .into_iter()
+ .map(|t| {
+ let name = t.name_any();
+ let namespace = t.namespace().unwrap_or_default();
+ let state = t
+ .status
+ .as_ref()
+ .map(|s| s.current_state.clone())
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ if !is_healthy_state(&state) {
+ unhealthy_count += 1;
+ }
+
+ let created_at = t
+ .metadata
+ .creation_timestamp
+ .as_ref()
+ .map(|ts| ts.0.to_rfc3339());
+
+ // Pool 信息
+ let pools: Vec = t
+ .spec
+ .pools
+ .iter()
+ .map(|spec_pool| {
+ let pool_status = t.status.as_ref().and_then(|s| {
+ s.pools
+ .iter()
+ .find(|sp| sp.ss_name.contains(&spec_pool.name))
+ });
+
+ let pool_state = pool_status
+ .map(|ps| map_pool_state(&ps.state))
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ let replicas = pool_status
+ .and_then(|ps| ps.replicas)
+ .unwrap_or(spec_pool.servers);
+
+ let per_volume_bytes = get_per_volume_bytes(&spec_pool.persistence);
+ let pool_capacity_bytes = (spec_pool.servers as i64)
+ * (spec_pool.persistence.volumes_per_server as i64)
+ * per_volume_bytes;
+
+ TopologyPool {
+ name: spec_pool.name.clone(),
+ state: pool_state,
+ servers: spec_pool.servers,
+ volumes_per_server: spec_pool.persistence.volumes_per_server,
+ replicas,
+ capacity: format_storage_bytes(pool_capacity_bytes),
+ }
+ })
+ .collect();
+
+ // Tenant 摘要
+ let pool_count = pools.len();
+ let total_replicas: i32 = pools.iter().map(|p| p.replicas).sum();
+ let total_capacity_bytes: i64 = t
+ .spec
+ .pools
+ .iter()
+ .map(|p| {
+ let per_vol = get_per_volume_bytes(&p.persistence);
+ (p.servers as i64) * (p.persistence.volumes_per_server as i64) * per_vol
+ })
+ .sum();
+
+ let endpoint = Some(format!("http://{}-io.{}.svc:9000", name, namespace));
+ let console_endpoint =
+ Some(format!("http://{}-console.{}.svc:9001", name, namespace));
+
+ // 匹配 pods
+ let key = (namespace.clone(), name.clone());
+ let tenant_pods = pod_index.remove(&key);
+
+ TopologyTenant {
+ name,
+ namespace,
+ state,
+ created_at,
+ summary: TopologyTenantSummary {
+ pool_count,
+ replicas: total_replicas,
+ capacity: format_storage_bytes(total_capacity_bytes),
+ capacity_bytes: total_capacity_bytes,
+ endpoint,
+ console_endpoint,
+ },
+ pools: Some(pools),
+ pods: tenant_pods,
+ }
+ })
+ .collect();
+
+ total_unhealthy += unhealthy_count;
+
+ TopologyNamespace {
+ name: ns_name,
+ tenant_count: tenant_items.len(),
+ unhealthy_tenant_count: unhealthy_count,
+ tenants: tenant_items,
+ }
+ })
+ .collect();
+
+ // 集群信息
+ let cluster = TopologyCluster {
+ id: "rustfs-cluster".to_string(),
+ name: std::env::var("CLUSTER_NAME").unwrap_or_else(|_| "RustFS Cluster".to_string()),
+ version: get_cluster_version(&client).await,
+ summary: TopologyClusterSummary {
+ nodes: nodes.len(),
+ namespaces: namespaces.len(),
+ tenants: k8s_tenants.items.len(),
+ unhealthy_tenants: total_unhealthy,
+ total_cpu: format_cpu_from_millicores(total_cpu_m),
+ total_memory: format_memory_from_bytes(total_mem_b),
+ allocatable_cpu: format_cpu_from_millicores(alloc_cpu_m),
+ allocatable_memory: format_memory_from_bytes(alloc_mem_b),
+ },
+ };
+
+ Ok(Json(TopologyOverviewResponse {
+ cluster,
+ namespaces,
+ nodes,
+ }))
+}
+
+/// 判断 Tenant 状态是否健康
+fn is_healthy_state(state: &str) -> bool {
+ matches!(state, "Ready" | "Initialized")
+}
+
+/// 将 PoolState 映射到前端状态字符串
+fn map_pool_state(state: &PoolState) -> String {
+ match state {
+ PoolState::Created | PoolState::Initialized | PoolState::RolloutComplete => {
+ "Ready".to_string()
+ }
+ PoolState::Updating => "Updating".to_string(),
+ PoolState::Degraded | PoolState::RolloutFailed => "Degraded".to_string(),
+ PoolState::NotCreated => "NotReady".to_string(),
+ }
+}
+
+/// 从 PersistenceConfig 获取每个 volume 的字节数
+fn get_per_volume_bytes(
+ persistence: &crate::types::v1alpha1::persistence::PersistenceConfig,
+) -> i64 {
+ const DEFAULT_BYTES: i64 = 10 * 1024 * 1024 * 1024; // 10Gi
+
+ persistence
+ .volume_claim_template
+ .as_ref()
+ .and_then(|vct| vct.resources.as_ref())
+ .and_then(|res| res.requests.as_ref())
+ .and_then(|req| req.get("storage"))
+ .map(|q| parse_memory_to_bytes(&q.0))
+ .unwrap_or(DEFAULT_BYTES)
+}
+
+/// 将字节数格式化为可读存储字符串(优先 TiB, GiB)
+fn format_storage_bytes(b: i64) -> String {
+ const TIB: i64 = 1024 * 1024 * 1024 * 1024;
+ const GIB: i64 = 1024 * 1024 * 1024;
+ const MIB: i64 = 1024 * 1024;
+
+ if b <= 0 {
+ return "0".to_string();
+ }
+ if b >= TIB && b % TIB == 0 {
+ format!("{} TiB", b / TIB)
+ } else if b >= TIB {
+ format!("{:.1} TiB", b as f64 / TIB as f64)
+ } else if b >= GIB && b % GIB == 0 {
+ format!("{} GiB", b / GIB)
+ } else if b >= GIB {
+ format!("{:.1} GiB", b as f64 / GIB as f64)
+ } else if b >= MIB && b % MIB == 0 {
+ format!("{} MiB", b / MIB)
+ } else {
+ format!("{} B", b)
+ }
+}
+
+/// 获取 Kubernetes 集群版本
+async fn get_cluster_version(client: &Client) -> String {
+ match client.apiserver_version().await {
+ Ok(info) => format!("v{}.{}", info.major, info.minor),
+ Err(_) => "unknown".to_string(),
+ }
+}
+
+/// 创建 Kubernetes 客户端
+async fn create_client(claims: &Claims) -> Result {
+ let mut config = kube::Config::infer()
+ .await
+ .map_err(|e| Error::InternalServer {
+ message: format!("Failed to load kubeconfig: {}", e),
+ })?;
+
+ config.auth_info.token = Some(claims.k8s_token.clone().into());
+
+ Client::try_from(config).map_err(|e| Error::InternalServer {
+ message: format!("Failed to create K8s client: {}", e),
+ })
+}
diff --git a/src/console/models/encryption.rs b/src/console/models/encryption.rs
new file mode 100644
index 0000000..6a5c7a2
--- /dev/null
+++ b/src/console/models/encryption.rs
@@ -0,0 +1,125 @@
+// Copyright 2025 RustFS Team
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+/// GET response – current encryption configuration for a Tenant.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct EncryptionInfoResponse {
+ pub enabled: bool,
+ pub backend: String,
+ pub vault: Option,
+ pub local: Option,
+ pub kms_secret_name: Option,
+ pub ping_seconds: Option,
+ pub security_context: Option,
+}
+
+/// Vault configuration (non-sensitive fields only).
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct VaultInfo {
+ pub endpoint: String,
+ pub engine: Option,
+ pub namespace: Option,
+ pub prefix: Option,
+ pub auth_type: Option,
+ pub app_role: Option,
+ pub tls_skip_verify: Option,
+ pub custom_certificates: Option,
+}
+
+/// AppRole non-sensitive fields.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AppRoleInfo {
+ pub engine: Option,
+ pub retry_seconds: Option,
+}
+
+/// Local KMS configuration.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct LocalInfo {
+ pub key_directory: Option,
+ pub master_key_id: Option,
+}
+
+/// SecurityContext information (lives at TenantSpec level, shown alongside encryption).
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SecurityContextInfo {
+ pub run_as_user: Option,
+ pub run_as_group: Option,
+ pub fs_group: Option,
+ pub run_as_non_root: Option,
+}
+
+/// PUT request – update encryption configuration.
+/// SecurityContext is managed separately via the Security tab (PUT .../security-context).
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateEncryptionRequest {
+ pub enabled: bool,
+ pub backend: Option,
+ pub vault: Option,
+ pub local: Option,
+ pub kms_secret_name: Option,
+ pub ping_seconds: Option,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateVaultRequest {
+ pub endpoint: String,
+ pub engine: Option,
+ pub namespace: Option,
+ pub prefix: Option,
+ pub auth_type: Option,
+ pub app_role: Option,
+ pub tls_skip_verify: Option,
+ pub custom_certificates: Option,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateAppRoleRequest {
+ pub engine: Option,
+ pub retry_seconds: Option,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateLocalRequest {
+ pub key_directory: Option,
+ pub master_key_id: Option,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateSecurityContextRequest {
+ pub run_as_user: Option,
+ pub run_as_group: Option,
+ pub fs_group: Option,
+ pub run_as_non_root: Option,
+}
+
+/// Generic success response.
+#[derive(Debug, Serialize, ToSchema)]
+pub struct EncryptionUpdateResponse {
+ pub success: bool,
+ pub message: String,
+}
diff --git a/src/console/models/mod.rs b/src/console/models/mod.rs
index 3523721..8e95467 100755
--- a/src/console/models/mod.rs
+++ b/src/console/models/mod.rs
@@ -14,7 +14,9 @@
pub mod auth;
pub mod cluster;
+pub mod encryption;
pub mod event;
pub mod pod;
pub mod pool;
pub mod tenant;
+pub mod topology;
diff --git a/src/console/models/tenant.rs b/src/console/models/tenant.rs
index 8bda3f3..5b194c2 100755
--- a/src/console/models/tenant.rs
+++ b/src/console/models/tenant.rs
@@ -39,6 +39,22 @@ pub struct TenantListResponse {
pub tenants: Vec,
}
+/// Tenant 列表查询参数
+#[derive(Debug, Deserialize, ToSchema, Default)]
+pub struct TenantListQuery {
+ /// 按状态过滤(大小写不敏感)
+ pub state: Option,
+}
+
+/// Tenant 状态统计响应
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TenantStateCountsResponse {
+ /// Tenant 总数
+ pub total: u32,
+ /// 各状态对应的数量,例如 Ready/Updating/Degraded/NotReady/Unknown
+ pub counts: std::collections::BTreeMap,
+}
+
/// Tenant 详情响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TenantDetailsResponse {
@@ -68,6 +84,16 @@ pub struct ServicePort {
pub target_port: String,
}
+/// SecurityContext for create/update (Pod runAsUser, runAsGroup, fsGroup, runAsNonRoot).
+#[derive(Debug, Deserialize, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateSecurityContextRequest {
+ pub run_as_user: Option,
+ pub run_as_group: Option,
+ pub fs_group: Option,
+ pub run_as_non_root: Option,
+}
+
/// 创建 Tenant 请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateTenantRequest {
@@ -77,6 +103,8 @@ pub struct CreateTenantRequest {
pub image: Option,
pub mount_path: Option,
pub creds_secret: Option,
+ /// Optional Pod SecurityContext override (runAsUser, runAsGroup, fsGroup, runAsNonRoot).
+ pub security_context: Option,
}
/// 创建 Pool 请求
@@ -145,3 +173,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/models/topology.rs b/src/console/models/topology.rs
new file mode 100644
index 0000000..4465141
--- /dev/null
+++ b/src/console/models/topology.rs
@@ -0,0 +1,113 @@
+// Copyright 2025 RustFS Team
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use serde::Serialize;
+use utoipa::ToSchema;
+
+/// 拓扑总览响应
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyOverviewResponse {
+ pub cluster: TopologyCluster,
+ pub namespaces: Vec,
+ pub nodes: Vec,
+}
+
+/// 集群信息
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyCluster {
+ pub id: String,
+ pub name: String,
+ pub version: String,
+ pub summary: TopologyClusterSummary,
+}
+
+/// 集群摘要
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyClusterSummary {
+ pub nodes: usize,
+ pub namespaces: usize,
+ pub tenants: usize,
+ pub unhealthy_tenants: usize,
+ pub total_cpu: String,
+ pub total_memory: String,
+ pub allocatable_cpu: String,
+ pub allocatable_memory: String,
+}
+
+/// 命名空间拓扑
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyNamespace {
+ pub name: String,
+ pub tenant_count: usize,
+ pub unhealthy_tenant_count: usize,
+ pub tenants: Vec,
+}
+
+/// Tenant 拓扑
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyTenant {
+ pub name: String,
+ pub namespace: String,
+ pub state: String,
+ pub created_at: Option,
+ pub summary: TopologyTenantSummary,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pools: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pods: Option>,
+}
+
+/// Tenant 摘要
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyTenantSummary {
+ pub pool_count: usize,
+ pub replicas: i32,
+ pub capacity: String,
+ pub capacity_bytes: i64,
+ pub endpoint: Option,
+ pub console_endpoint: Option,
+}
+
+/// Pool 拓扑
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyPool {
+ pub name: String,
+ pub state: String,
+ pub servers: i32,
+ pub volumes_per_server: i32,
+ pub replicas: i32,
+ pub capacity: String,
+}
+
+/// Pod 拓扑
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyPod {
+ pub name: String,
+ pub pool: String,
+ pub phase: String,
+ pub ready: String,
+ pub node: Option,
+}
+
+/// 节点信息
+#[derive(Debug, Serialize, ToSchema)]
+pub struct TopologyNode {
+ pub name: String,
+ pub status: String,
+ pub roles: Vec,
+ pub cpu_capacity: String,
+ pub memory_capacity: String,
+ pub cpu_allocatable: String,
+ pub memory_allocatable: String,
+}
diff --git a/src/console/openapi.rs b/src/console/openapi.rs
index 13d4224..a58944f 100644
--- a/src/console/openapi.rs
+++ b/src/console/openapi.rs
@@ -36,8 +36,13 @@ use crate::console::models::pool::{
};
use crate::console::models::tenant::{
CreatePoolRequest, CreateTenantRequest, DeleteTenantResponse, EnvVar, LoggingConfig, PoolInfo,
- ServiceInfo, ServicePort, TenantDetailsResponse, TenantListItem, TenantListResponse,
- UpdateTenantRequest, UpdateTenantResponse,
+ ServiceInfo, ServicePort, TenantDetailsResponse, TenantListItem, TenantListQuery,
+ TenantListResponse, TenantStateCountsResponse, TenantYAML, UpdateTenantRequest,
+ UpdateTenantResponse,
+};
+use crate::console::models::topology::{
+ TopologyCluster, TopologyClusterSummary, TopologyNamespace, TopologyNode,
+ TopologyOverviewResponse, TopologyPod, TopologyPool, TopologyTenant, TopologyTenantSummary,
};
#[derive(OpenApi)]
@@ -47,11 +52,15 @@ use crate::console::models::tenant::{
api_logout,
api_session,
api_list_tenants,
+ api_get_tenant_state_counts,
api_create_tenant,
api_list_tenants_by_ns,
+ api_get_tenant_state_counts_by_ns,
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,
@@ -65,6 +74,7 @@ use crate::console::models::tenant::{
api_get_cluster_resources,
api_list_namespaces,
api_create_namespace,
+ api_get_topology_overview,
),
components(schemas(
LoginRequest,
@@ -72,6 +82,8 @@ use crate::console::models::tenant::{
SessionResponse,
TenantListItem,
TenantListResponse,
+ TenantListQuery,
+ TenantStateCountsResponse,
TenantDetailsResponse,
CreateTenantRequest,
CreatePoolRequest,
@@ -83,6 +95,7 @@ use crate::console::models::tenant::{
UpdateTenantRequest,
UpdateTenantResponse,
DeleteTenantResponse,
+ TenantYAML,
PoolDetails,
PoolListResponse,
AddPoolRequest,
@@ -108,6 +121,15 @@ use crate::console::models::tenant::{
NamespaceItem,
NamespaceListResponse,
CreateNamespaceRequest,
+ TopologyOverviewResponse,
+ TopologyCluster,
+ TopologyClusterSummary,
+ TopologyNamespace,
+ TopologyTenant,
+ TopologyTenantSummary,
+ TopologyPool,
+ TopologyPod,
+ TopologyNode,
)),
tags(
(name = "auth", description = "Authentication"),
@@ -116,6 +138,7 @@ use crate::console::models::tenant::{
(name = "pods", description = "Pod management"),
(name = "events", description = "Event management"),
(name = "cluster", description = "Cluster resources"),
+ (name = "topology", description = "Cluster topology overview"),
),
info(
title = "RustFS Console API",
@@ -140,21 +163,46 @@ fn api_session() -> Json {
}
// --- Tenants ---
-#[utoipa::path(get, path = "/api/v1/tenants", responses((status = 200, body = TenantListResponse)), tag = "tenants")]
+#[utoipa::path(
+ get,
+ path = "/api/v1/tenants",
+ params(("state" = Option, Query, description = "Filter by tenant state (case-insensitive)")),
+ responses((status = 200, body = TenantListResponse)),
+ tag = "tenants"
+)]
fn api_list_tenants() -> Json {
unimplemented!("Documentation only")
}
+#[utoipa::path(get, path = "/api/v1/tenants/state-counts", responses((status = 200, body = TenantStateCountsResponse)), tag = "tenants")]
+fn api_get_tenant_state_counts() -> Json {
+ unimplemented!("Documentation only")
+}
+
#[utoipa::path(post, path = "/api/v1/tenants", request_body = CreateTenantRequest, responses((status = 200, body = TenantListItem)), tag = "tenants")]
fn api_create_tenant(_body: Json) -> Json {
unimplemented!("Documentation only")
}
-#[utoipa::path(get, path = "/api/v1/namespaces/{namespace}/tenants", params(("namespace" = String, Path, description = "Namespace")), responses((status = 200, body = TenantListResponse)), tag = "tenants")]
+#[utoipa::path(
+ get,
+ path = "/api/v1/namespaces/{namespace}/tenants",
+ params(
+ ("namespace" = String, Path, description = "Namespace"),
+ ("state" = Option, Query, description = "Filter by tenant state (case-insensitive)")
+ ),
+ responses((status = 200, body = TenantListResponse)),
+ tag = "tenants"
+)]
fn api_list_tenants_by_ns() -> Json