From 06ecb8a54696c97cd8910048b83c7e639bb1e056 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 00:59:29 +0300 Subject: [PATCH 01/40] feat(console): add RJSF custom templates and widgets - Add CustomObjectFieldTemplate for free-form key-value objects - Add KeyValueEditor component for Helm valuesOverride fields - Add SourceWidget for mutually exclusive source selection (VM Disk) - Add custom button templates (Add, Remove, MoveUp, MoveDown) - Fix select dropdown arrow positioning with proper padding - Install @novnc/novnc for VNC viewer support Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: ohotnikov.ivan --- apps/console/package.json | 1 + .../components/CustomObjectFieldTemplate.tsx | 56 ++++++++ .../console/src/components/KeyValueEditor.tsx | 103 ++++++++++++++ apps/console/src/components/SchemaForm.tsx | 7 + apps/console/src/components/SourceWidget.tsx | 131 ++++++++++++++++++ .../console/src/components/rjsf-templates.tsx | 58 ++++++++ apps/console/src/components/schema-form.css | 6 +- pnpm-lock.yaml | 8 ++ 8 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 apps/console/src/components/CustomObjectFieldTemplate.tsx create mode 100644 apps/console/src/components/KeyValueEditor.tsx create mode 100644 apps/console/src/components/SourceWidget.tsx create mode 100644 apps/console/src/components/rjsf-templates.tsx diff --git a/apps/console/package.json b/apps/console/package.json index 173f404..b61eccd 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -15,6 +15,7 @@ "@cozystack/types": "workspace:*", "@cozystack/ui": "workspace:*", "@monaco-editor/react": "^4.7.0", + "@novnc/novnc": "^1.6.0", "@rjsf/core": "^5.24.8", "@rjsf/utils": "^5.24.8", "@rjsf/validator-ajv8": "^5.24.8", diff --git a/apps/console/src/components/CustomObjectFieldTemplate.tsx b/apps/console/src/components/CustomObjectFieldTemplate.tsx new file mode 100644 index 0000000..9bbc16a --- /dev/null +++ b/apps/console/src/components/CustomObjectFieldTemplate.tsx @@ -0,0 +1,56 @@ +import type { + ObjectFieldTemplateProps, + RJSFSchema, + StrictRJSFSchema, + FormContextType, +} from "@rjsf/utils" +import { KeyValueEditor } from "./KeyValueEditor.tsx" +import { MutuallyExclusiveField } from "./MutuallyExclusiveField.tsx" + +export function CustomObjectFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ObjectFieldTemplateProps) { + const { schema, formData, onChange, readonly, disabled, uiSchema } = props + + // Check if this is a free-form key-value object + // Must have x-kubernetes-preserve-unknown-fields OR explicit additionalProperties: true + // This excludes empty marker objects (like upload: {}) which have no properties and no additionalProperties + const hasExplicitAdditionalProps = + (schema as any)["x-kubernetes-preserve-unknown-fields"] === true || + schema.additionalProperties === true + + const isFreeFormObject = + (!schema.properties || Object.keys(schema.properties).length === 0) && + hasExplicitAdditionalProps + + // If it's a free-form key-value object, use our custom editor + if (isFreeFormObject) { + return ( +
+ {props.title && ( + + )} + {props.description &&

{props.description}

} + +
+ ) + } + + // Otherwise, use default rendering + return ( +
+ {props.title && {props.title}} + {props.description &&

{props.description}

} + {props.properties.map((prop) => prop.content)} +
+ ) +} diff --git a/apps/console/src/components/KeyValueEditor.tsx b/apps/console/src/components/KeyValueEditor.tsx new file mode 100644 index 0000000..dfce28c --- /dev/null +++ b/apps/console/src/components/KeyValueEditor.tsx @@ -0,0 +1,103 @@ +import { useState } from "react" + +interface KeyValuePair { + key: string + value: string +} + +interface KeyValueEditorProps { + value: Record + onChange: (value: Record) => void + readonly?: boolean +} + +export function KeyValueEditor({ value, onChange, readonly }: KeyValueEditorProps) { + const [pairs, setPairs] = useState(() => { + return Object.entries(value || {}).map(([key, val]) => ({ + key, + value: typeof val === "string" ? val : JSON.stringify(val), + })) + }) + + const updatePairs = (newPairs: KeyValuePair[]) => { + setPairs(newPairs) + const obj: Record = {} + for (const pair of newPairs) { + if (pair.key.trim()) { + try { + obj[pair.key] = JSON.parse(pair.value) + } catch { + obj[pair.key] = pair.value + } + } + } + onChange(obj) + } + + const addPair = () => { + updatePairs([...pairs, { key: "", value: "" }]) + } + + const removePair = (index: number) => { + updatePairs(pairs.filter((_, i) => i !== index)) + } + + const updateKey = (index: number, key: string) => { + const newPairs = [...pairs] + newPairs[index] = { ...newPairs[index], key } + updatePairs(newPairs) + } + + const updateValue = (index: number, value: string) => { + const newPairs = [...pairs] + newPairs[index] = { ...newPairs[index], value } + updatePairs(newPairs) + } + + if (readonly && pairs.length === 0) { + return
No overrides
+ } + + return ( +
+ {pairs.map((pair, index) => ( +
+ updateKey(index, e.target.value)} + disabled={readonly} + className="flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 disabled:bg-slate-50 disabled:text-slate-500" + /> + updateValue(index, e.target.value)} + disabled={readonly} + className="flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 disabled:bg-slate-50 disabled:text-slate-500" + /> + {!readonly && ( + + )} +
+ ))} + {!readonly && ( + + )} +
+ ) +} diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index ec5c7d9..78dcc10 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -3,6 +3,7 @@ import Form from "@rjsf/core" import validator from "@rjsf/validator-ajv8" import type { RJSFSchema, UiSchema } from "@rjsf/utils" import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts" +import { customTemplates, customWidgets } from "./rjsf-templates.tsx" import "./schema-form.css" interface SchemaFormProps { @@ -32,6 +33,10 @@ export function SchemaForm({ () => ({ "ui:submitButtonOptions": { norender: true }, ...keysOrderToUiSchema(keysOrder), + // Use SourceWidget for mutually exclusive source fields + source: { + "ui:widget": "SourceWidget", + }, }), [keysOrder], ) @@ -43,6 +48,8 @@ export function SchemaForm({ uiSchema={uiSchema} formData={formData} validator={validator} + templates={customTemplates} + widgets={customWidgets} onChange={(e) => onChange(e.formData)} liveValidate={false} showErrorList={false} diff --git a/apps/console/src/components/SourceWidget.tsx b/apps/console/src/components/SourceWidget.tsx new file mode 100644 index 0000000..c80cea9 --- /dev/null +++ b/apps/console/src/components/SourceWidget.tsx @@ -0,0 +1,131 @@ +import { useState } from "react" +import type { WidgetProps } from "@rjsf/utils" + +export function SourceWidget(props: WidgetProps) { + const { schema, value, onChange, id, label, required } = props + const properties = schema.properties || {} + const options = Object.keys(properties) + + // Determine which option is currently selected + const currentOption = value + ? options.find((opt: string) => value[opt] !== undefined && value[opt] !== null) + : undefined + + const [selected, setSelected] = useState(currentOption) + + const handleSelect = (option: string) => { + setSelected(option) + const prop = properties[option] + // If the property is an empty object marker (like upload: {}), set it to {} + // Otherwise initialize with default value or empty object + const defaultValue = + prop && typeof prop === "object" && Object.keys((prop as any).properties || {}).length === 0 + ? {} + : prop && typeof prop === "object" && (prop as any).type === "object" + ? {} + : "" + onChange({ [option]: defaultValue }) + } + + const handleClear = () => { + setSelected(undefined) + onChange(undefined) + } + + const renderFieldInput = (option: string) => { + const prop = properties[option] as any + if (!prop || typeof prop !== "object") return null + + // If it's an empty object marker (like upload: {}), show nothing + if (Object.keys(prop.properties || {}).length === 0) { + return ( +
+ {prop.description || "No additional configuration needed"} +
+ ) + } + + // Render input fields for the selected option + const subProps = prop.properties || {} + return ( +
+ {Object.entries(subProps).map(([key, subProp]: [string, any]) => ( +
+ + {subProp.description && ( +

{subProp.description}

+ )} + )?.[key] || ""} + onChange={(e) => { + onChange({ + [option]: { + ...(value?.[option] as Record), + [key]: e.target.value, + }, + }) + }} + placeholder={subProp.title || key} + className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400" + /> +
+ ))} +
+ ) + } + + return ( +
+ {label && ( + + )} + {schema.description &&

{schema.description}

} + +
+ {options.map((option: string) => { + const prop = properties[option] as any + const optionDescription = + typeof prop === "object" && prop ? prop.description : undefined + const isSelected = selected === option + + return ( +
+ +
+ ) + })} + {selected && ( + + )} +
+
+ ) +} diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx new file mode 100644 index 0000000..cd76d26 --- /dev/null +++ b/apps/console/src/components/rjsf-templates.tsx @@ -0,0 +1,58 @@ +import type { + IconButtonProps, + TemplatesType, + WidgetsType, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" +import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" +import { SourceWidget } from "./SourceWidget.tsx" + +function IconButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: IconButtonProps) { + const { icon, iconType, uiSchema, registry, className, ...btnProps } = props + return ( + + ) +} + +const buttonClassName = + "rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50" + +const removeButtonClassName = + "rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50" + +export const customTemplates: Partial = { + ObjectFieldTemplate: CustomObjectFieldTemplate, + ButtonTemplates: { + AddButton: (props) => ( + + ), + RemoveButton: (props) => ( + + ), + MoveUpButton: (props) => ( + + ), + MoveDownButton: (props) => ( + + ), + SubmitButton: (props) => ( + + ), + }, +} + +export const customWidgets: Partial = { + SourceWidget: SourceWidget, +} diff --git a/apps/console/src/components/schema-form.css b/apps/console/src/components/schema-form.css index 80c5ecf..1a29fa3 100644 --- a/apps/console/src/components/schema-form.css +++ b/apps/console/src/components/schema-form.css @@ -20,10 +20,12 @@ .rjsf-container input[type="text"], .rjsf-container input[type="number"], .rjsf-container input[type="email"], -.rjsf-container textarea, -.rjsf-container select { +.rjsf-container textarea { @apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400; } +.rjsf-container select { + @apply w-full rounded-lg border border-slate-300 bg-white pl-3 pr-8 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400; +} .rjsf-container input[type="checkbox"] { @apply mr-2 size-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b77ba68..f319e08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@novnc/novnc': + specifier: ^1.6.0 + version: 1.6.0 '@rjsf/core': specifier: ^5.24.8 version: 5.24.13(@rjsf/utils@5.24.13(react@19.2.5))(react@19.2.5) @@ -382,6 +385,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@novnc/novnc@1.6.0': + resolution: {integrity: sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==} + '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} @@ -1721,6 +1727,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@novnc/novnc@1.6.0': {} + '@oxc-project/types@0.124.0': {} '@rjsf/core@5.24.13(@rjsf/utils@5.24.13(react@19.2.5))(react@19.2.5)': From c430e41993b7cdc293556ddec655293a0f28732e Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 00:59:36 +0300 Subject: [PATCH 02/40] feat(console): improve secrets display with formatting - Add pre-formatted text display for kubeconfig and large secrets - Add collapse/expand functionality for large secrets - Add dark terminal-like theme for better readability - Show "Show more/Show less" button for secrets larger than 5 lines or 200 chars Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: ohotnikov.ivan --- apps/console/src/routes/detail/SecretsTab.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/console/src/routes/detail/SecretsTab.tsx b/apps/console/src/routes/detail/SecretsTab.tsx index c0ce2f8..3c20c9a 100644 --- a/apps/console/src/routes/detail/SecretsTab.tsx +++ b/apps/console/src/routes/detail/SecretsTab.tsx @@ -43,6 +43,7 @@ function SecretRow({ base64Value: string }) { const [revealed, setRevealed] = useState(false) + const [expanded, setExpanded] = useState(false) const { data } = useK8sGet & SecretLike>( { ...TENANT_SECRETS_REF, namespace, name }, { enabled: revealed }, @@ -50,17 +51,35 @@ function SecretRow({ const fullValue = revealed ? decodeValue(data?.data?.[keyName]) || data?.stringData?.[keyName] || decodeValue(base64Value) : "" + + const isLarge = fullValue.split('\n').length > 5 || fullValue.length > 200 + return ( -
- {keyName} -
+
+ {keyName} +
{revealed ? ( - {fullValue || "(empty)"} + <> +
+              {fullValue || "(empty)"}
+            
+ {isLarge && ( + + )} + ) : ( •••••••• )}
-
+
+ + +
+
) diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 03fa66a..7579384 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -21,10 +21,12 @@ export default defineConfig({ "/apis": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, "/api": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, }, }, From 670c9d889ef60550cfcafae6c3df48d148e37b42 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 01:03:41 +0300 Subject: [PATCH 05/40] feat(console): add StorageClassWidget for storage class selection - Fetch StorageClasses from Kubernetes API using useK8sList - Display in select dropdown with (default) marker - Auto-select default StorageClass if no value set - Handle loading and empty states - Register in customWidgets for RJSF forms Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: ohotnikov.ivan --- .../src/components/StorageClassWidget.tsx | 74 +++++++++++++++++++ .../console/src/components/rjsf-templates.tsx | 2 + 2 files changed, 76 insertions(+) create mode 100644 apps/console/src/components/StorageClassWidget.tsx diff --git a/apps/console/src/components/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx new file mode 100644 index 0000000..7113246 --- /dev/null +++ b/apps/console/src/components/StorageClassWidget.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react" +import type { WidgetProps } from "@rjsf/utils" +import { useK8sList } from "@cozystack/k8s-client" + +interface StorageClass { + apiVersion: string + kind: string + metadata: { + name: string + annotations?: Record + } + provisioner: string + parameters?: Record +} + +export function StorageClassWidget(props: WidgetProps) { + const { value, onChange, id, label, required, readonly, disabled } = props + + // Fetch StorageClasses from Kubernetes API + const { data: storageClasses, isLoading } = useK8sList({ + apiVersion: "storage.k8s.io/v1", + kind: "StorageClass", + }) + + const classes = storageClasses?.items || [] + + // Find default StorageClass + const defaultClass = classes.find( + (sc) => sc.metadata.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" + ) + + // Set default value if not set and default class exists + useEffect(() => { + if (!value && defaultClass && !readonly && !disabled) { + onChange(defaultClass.metadata.name) + } + }, [value, defaultClass, readonly, disabled, onChange]) + + return ( +
+ {label && ( + + )} + {isLoading ? ( +
Loading storage classes...
+ ) : ( + + )} + {classes.length === 0 && !isLoading && ( +

No StorageClasses available

+ )} +
+ ) +} diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx index cd76d26..e09e599 100644 --- a/apps/console/src/components/rjsf-templates.tsx +++ b/apps/console/src/components/rjsf-templates.tsx @@ -8,6 +8,7 @@ import type { } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" +import { StorageClassWidget } from "./StorageClassWidget.tsx" function IconButton< T = any, @@ -55,4 +56,5 @@ export const customTemplates: Partial = { export const customWidgets: Partial = { SourceWidget: SourceWidget, + StorageClassWidget: StorageClassWidget, } From 286c79ef661a9df0b199ead703aa3ed0483e25a8 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 01:11:17 +0300 Subject: [PATCH 06/40] refactor(console): improve RJSF form widgets - Add MutuallyExclusiveField component for radio button selection - Remove unused StorageClassWidget - Clean up rjsf-templates exports Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: ohotnikov.ivan --- .../src/components/MutuallyExclusiveField.tsx | 141 ++++++++++++++++++ .../src/components/StorageClassWidget.tsx | 74 --------- .../console/src/components/rjsf-templates.tsx | 2 - 3 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 apps/console/src/components/MutuallyExclusiveField.tsx diff --git a/apps/console/src/components/MutuallyExclusiveField.tsx b/apps/console/src/components/MutuallyExclusiveField.tsx new file mode 100644 index 0000000..6fa59b7 --- /dev/null +++ b/apps/console/src/components/MutuallyExclusiveField.tsx @@ -0,0 +1,141 @@ +import { useState } from "react" +import type { RJSFSchema } from "@rjsf/utils" + +interface MutuallyExclusiveFieldProps { + schema: RJSFSchema + formData: Record | undefined + handleChange: (data: Record | undefined) => void + title?: string + description?: string + required?: boolean +} + +export function MutuallyExclusiveField(props: MutuallyExclusiveFieldProps) { + const { schema, formData, onChange, title, description, required } = props + const handleChange = onChange + const properties = schema.properties || {} + const options = Object.keys(properties) + + // Determine which option is currently selected + const currentOption = formData + ? options.find((opt) => formData[opt] !== undefined && formData[opt] !== null) + : undefined + + const [selected, setSelected] = useState(currentOption) + + const handleSelect = (option: string) => { + setSelected(option) + const prop = properties[option] + // If the property is an empty object marker (like upload: {}), set it to {} + // Otherwise initialize with default value or empty object + const defaultValue = + prop && typeof prop === "object" && Object.keys(prop.properties || {}).length === 0 + ? {} + : prop && typeof prop === "object" && prop.type === "object" + ? {} + : "" + handleChange({ [option]: defaultValue }) + } + + const handleClear = () => { + setSelected(undefined) + handleChange(undefined) + } + + const renderFieldInput = (option: string) => { + const prop = properties[option] + if (!prop || typeof prop !== "object") return null + + // If it's an empty object marker (like upload: {}), show nothing + if (Object.keys(prop.properties || {}).length === 0) { + return ( +
+ {prop.description || "No additional configuration needed"} +
+ ) + } + + // Render input fields for the selected option + const subProps = prop.properties || {} + return ( +
+ {Object.entries(subProps).map(([key, subProp]: [string, any]) => ( +
+ + {subProp.description && ( +

{subProp.description}

+ )} + )?.[key] || ""} + onChange={(e) => { + handleChange({ + [option]: { + ...(formData?.[option] as Record), + [key]: e.target.value, + }, + }) + }} + placeholder={subProp.title || key} + className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400" + /> +
+ ))} +
+ ) + } + + return ( +
+ {title && ( + + )} + {description &&

{description}

} + +
+ {options.map((option) => { + const prop = properties[option] + const optionDescription = + typeof prop === "object" && prop ? prop.description : undefined + const isSelected = selected === option + + return ( +
+ +
+ ) + })} + {selected && ( + + )} +
+
+ ) +} diff --git a/apps/console/src/components/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx index 7113246..e69de29 100644 --- a/apps/console/src/components/StorageClassWidget.tsx +++ b/apps/console/src/components/StorageClassWidget.tsx @@ -1,74 +0,0 @@ -import { useEffect, useState } from "react" -import type { WidgetProps } from "@rjsf/utils" -import { useK8sList } from "@cozystack/k8s-client" - -interface StorageClass { - apiVersion: string - kind: string - metadata: { - name: string - annotations?: Record - } - provisioner: string - parameters?: Record -} - -export function StorageClassWidget(props: WidgetProps) { - const { value, onChange, id, label, required, readonly, disabled } = props - - // Fetch StorageClasses from Kubernetes API - const { data: storageClasses, isLoading } = useK8sList({ - apiVersion: "storage.k8s.io/v1", - kind: "StorageClass", - }) - - const classes = storageClasses?.items || [] - - // Find default StorageClass - const defaultClass = classes.find( - (sc) => sc.metadata.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" - ) - - // Set default value if not set and default class exists - useEffect(() => { - if (!value && defaultClass && !readonly && !disabled) { - onChange(defaultClass.metadata.name) - } - }, [value, defaultClass, readonly, disabled, onChange]) - - return ( -
- {label && ( - - )} - {isLoading ? ( -
Loading storage classes...
- ) : ( - - )} - {classes.length === 0 && !isLoading && ( -

No StorageClasses available

- )} -
- ) -} diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx index e09e599..cd76d26 100644 --- a/apps/console/src/components/rjsf-templates.tsx +++ b/apps/console/src/components/rjsf-templates.tsx @@ -8,7 +8,6 @@ import type { } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" -import { StorageClassWidget } from "./StorageClassWidget.tsx" function IconButton< T = any, @@ -56,5 +55,4 @@ export const customTemplates: Partial = { export const customWidgets: Partial = { SourceWidget: SourceWidget, - StorageClassWidget: StorageClassWidget, } From 7a571b8510a90bfe24483baadf4ce555a1ceff95 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 01:15:40 +0300 Subject: [PATCH 07/40] fix(k8s-client): add labelSelector support to watch requests Watch requests were not filtering by labelSelector/fieldSelector, causing all resources in namespace to be returned instead of only those matching the filter. - Add search parameter to client.watch() method - Pass labelSelector and fieldSelector to watch URLSearchParams - Update hooks to pass selectors from useK8sList to client.watch() This fixes the issue where Secrets tab showed all namespace secrets instead of only those belonging to the current application. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: ohotnikov.ivan --- packages/k8s-client/src/client.ts | 3 +++ packages/k8s-client/src/hooks.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/k8s-client/src/client.ts b/packages/k8s-client/src/client.ts index e52c097..ecfd2ed 100644 --- a/packages/k8s-client/src/client.ts +++ b/packages/k8s-client/src/client.ts @@ -197,6 +197,7 @@ export class K8sClient { resourceVersion: string, onEvent: (event: WatchEvent) => void, onError?: (error: Error) => void, + search?: { labelSelector?: string; fieldSelector?: string }, ): () => void { const path = this.buildPath(apiGroup, apiVersion, plural, namespace) const params = new URLSearchParams({ @@ -204,6 +205,8 @@ export class K8sClient { resourceVersion, allowWatchBookmarks: "true", }) + if (search?.labelSelector) params.set("labelSelector", search.labelSelector) + if (search?.fieldSelector) params.set("fieldSelector", search.fieldSelector) const controller = new AbortController() diff --git a/packages/k8s-client/src/hooks.ts b/packages/k8s-client/src/hooks.ts index ea72f00..071de2b 100644 --- a/packages/k8s-client/src/hooks.ts +++ b/packages/k8s-client/src/hooks.ts @@ -90,6 +90,10 @@ export function useK8sList( queryClient.invalidateQueries({ queryKey }) }, 1000) }, + { + labelSelector, + fieldSelector, + }, ) return () => { From bed1285085abd5daf30fe8c52f5dee36245490b7 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 01:42:32 +0300 Subject: [PATCH 08/40] feat(console): add StorageClass dropdown widget Create StorageClassWidget that dynamically loads storage classes from Kubernetes API and displays them in a dropdown instead of text input. - Add StorageClassWidget component with K8s API integration - Register widget in RJSF custom widgets - Auto-apply widget to all storageClass fields via uiSchema - Remove duplicate label rendering (RJSF handles it) Signed-off-by: ohotnikov.ivan --- apps/console/src/components/SchemaForm.tsx | 4 ++ .../src/components/StorageClassWidget.tsx | 54 +++++++++++++++++++ .../console/src/components/rjsf-templates.tsx | 2 + 3 files changed, 60 insertions(+) diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index 78dcc10..0101eb7 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -37,6 +37,10 @@ export function SchemaForm({ source: { "ui:widget": "SourceWidget", }, + // Use StorageClassWidget for storageClass fields + storageClass: { + "ui:widget": "StorageClassWidget", + }, }), [keysOrder], ) diff --git a/apps/console/src/components/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx index e69de29..2253d2c 100644 --- a/apps/console/src/components/StorageClassWidget.tsx +++ b/apps/console/src/components/StorageClassWidget.tsx @@ -0,0 +1,54 @@ +import type { WidgetProps } from "@rjsf/utils" +import { useK8sList } from "@cozystack/k8s-client" +import { Spinner } from "@cozystack/ui" + +interface StorageClass { + apiVersion: string + kind: string + metadata: { + name: string + } +} + +export function StorageClassWidget(props: WidgetProps) { + const { value, onChange, id, label, required, readonly, disabled } = props + + const { data, isLoading } = useK8sList( + { + apiGroup: "storage.k8s.io", + apiVersion: "v1", + plural: "storageclasses", + }, + { enabled: true } + ) + + const storageClasses = data?.items ?? [] + + if (isLoading) { + return ( +
+ Loading storage classes... +
+ ) + } + + return ( +
+ +
+ ) +} + diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx index cd76d26..e09e599 100644 --- a/apps/console/src/components/rjsf-templates.tsx +++ b/apps/console/src/components/rjsf-templates.tsx @@ -8,6 +8,7 @@ import type { } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" +import { StorageClassWidget } from "./StorageClassWidget.tsx" function IconButton< T = any, @@ -55,4 +56,5 @@ export const customTemplates: Partial = { export const customWidgets: Partial = { SourceWidget: SourceWidget, + StorageClassWidget: StorageClassWidget, } From 1c08c51239582c4758454582f0b59cedcf3560d8 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Fri, 24 Apr 2026 01:48:03 +0300 Subject: [PATCH 09/40] feat(console): improve tenants management and UI - Add overflow scrolling to sidebar for long menus - Fix TenantsPage to show Tenant CR from current namespace - Add Edit button for each tenant linking to edit form - Fix CustomObjectFieldTemplate to support additionalProperties as object - Enable resourceQuotas field editing with KeyValueEditor Signed-off-by: ohotnikov.ivan --- .../components/CustomObjectFieldTemplate.tsx | 5 +- apps/console/src/components/SchemaForm.tsx | 4 - .../src/components/StorageClassWidget.tsx | 54 ------------- .../console/src/components/rjsf-templates.tsx | 2 - apps/console/src/routes/TenantsPage.tsx | 76 ++++++++----------- packages/ui/src/components/layout/Sidebar.tsx | 2 +- 6 files changed, 37 insertions(+), 106 deletions(-) diff --git a/apps/console/src/components/CustomObjectFieldTemplate.tsx b/apps/console/src/components/CustomObjectFieldTemplate.tsx index 9bbc16a..b562294 100644 --- a/apps/console/src/components/CustomObjectFieldTemplate.tsx +++ b/apps/console/src/components/CustomObjectFieldTemplate.tsx @@ -15,11 +15,12 @@ export function CustomObjectFieldTemplate< const { schema, formData, onChange, readonly, disabled, uiSchema } = props // Check if this is a free-form key-value object - // Must have x-kubernetes-preserve-unknown-fields OR explicit additionalProperties: true + // Must have x-kubernetes-preserve-unknown-fields OR explicit additionalProperties (true or object schema) // This excludes empty marker objects (like upload: {}) which have no properties and no additionalProperties const hasExplicitAdditionalProps = (schema as any)["x-kubernetes-preserve-unknown-fields"] === true || - schema.additionalProperties === true + schema.additionalProperties === true || + (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) const isFreeFormObject = (!schema.properties || Object.keys(schema.properties).length === 0) && diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index 0101eb7..78dcc10 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -37,10 +37,6 @@ export function SchemaForm({ source: { "ui:widget": "SourceWidget", }, - // Use StorageClassWidget for storageClass fields - storageClass: { - "ui:widget": "StorageClassWidget", - }, }), [keysOrder], ) diff --git a/apps/console/src/components/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx index 2253d2c..e69de29 100644 --- a/apps/console/src/components/StorageClassWidget.tsx +++ b/apps/console/src/components/StorageClassWidget.tsx @@ -1,54 +0,0 @@ -import type { WidgetProps } from "@rjsf/utils" -import { useK8sList } from "@cozystack/k8s-client" -import { Spinner } from "@cozystack/ui" - -interface StorageClass { - apiVersion: string - kind: string - metadata: { - name: string - } -} - -export function StorageClassWidget(props: WidgetProps) { - const { value, onChange, id, label, required, readonly, disabled } = props - - const { data, isLoading } = useK8sList( - { - apiGroup: "storage.k8s.io", - apiVersion: "v1", - plural: "storageclasses", - }, - { enabled: true } - ) - - const storageClasses = data?.items ?? [] - - if (isLoading) { - return ( -
- Loading storage classes... -
- ) - } - - return ( -
- -
- ) -} - diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx index e09e599..cd76d26 100644 --- a/apps/console/src/components/rjsf-templates.tsx +++ b/apps/console/src/components/rjsf-templates.tsx @@ -8,7 +8,6 @@ import type { } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" -import { StorageClassWidget } from "./StorageClassWidget.tsx" function IconButton< T = any, @@ -56,5 +55,4 @@ export const customTemplates: Partial = { export const customWidgets: Partial = { SourceWidget: SourceWidget, - StorageClassWidget: StorageClassWidget, } diff --git a/apps/console/src/routes/TenantsPage.tsx b/apps/console/src/routes/TenantsPage.tsx index 795aef0..70bf96a 100644 --- a/apps/console/src/routes/TenantsPage.tsx +++ b/apps/console/src/routes/TenantsPage.tsx @@ -1,28 +1,25 @@ import { Link } from "react-router" -import { Plus } from "lucide-react" -import { Spinner, Section, Button } from "@cozystack/ui" -import type { TenantNamespace } from "@cozystack/types" -import { tenantDisplayName, useTenantContext } from "../lib/tenant-context.tsx" -import { formatAge } from "../lib/status.ts" +import { Plus, Edit } from "lucide-react" +import { Spinner, Section, Button, StatusBadge } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" +import { APPS_GROUP, APPS_VERSION, type ApplicationInstance } from "@cozystack/types" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { formatAge, readyCondition } from "../lib/status.ts" -const MODULE_LABELS: { key: string; label: string }[] = [ - { key: "namespace.cozystack.io/etcd", label: "etcd" }, - { key: "namespace.cozystack.io/ingress", label: "ingress" }, - { key: "namespace.cozystack.io/monitoring", label: "monitoring" }, - { key: "namespace.cozystack.io/seaweedfs", label: "seaweedfs" }, -] +export function TenantsPage() { + const { tenantNamespace } = useTenantContext() -function enabledModules(ns: TenantNamespace): string[] { - const labels = ns.metadata.labels ?? {} - return MODULE_LABELS.filter((m) => labels[m.key] != null).map((m) => m.label) -} + const { data, isLoading } = useK8sList( + { + apiGroup: APPS_GROUP, + apiVersion: APPS_VERSION, + plural: "tenants", + namespace: tenantNamespace ?? "", + }, + { enabled: !!tenantNamespace } + ) -function tenantHost(ns: TenantNamespace): string | undefined { - return ns.metadata.labels?.["namespace.cozystack.io/host"] -} - -export function TenantsPage() { - const { tenants, isLoading } = useTenantContext() + const tenants = data?.items ?? [] return (
@@ -45,7 +42,7 @@ export function TenantsPage() {
) : tenants.length === 0 ? (
-

No tenants yet.

+

No tenants in this namespace yet.

) : (
@@ -53,38 +50,24 @@ export function TenantsPage() { Name - Namespace - Host - Modules + Status Age + Actions {tenants.map((t) => { - const name = tenantDisplayName(t) - const modules = enabledModules(t) - const host = tenantHost(t) + const ready = readyCondition(t) return ( - {name} - - {t.metadata.name} - {host ?? "—"} - {modules.length > 0 ? ( -
- {modules.map((m) => ( - - {m} - - ))} -
+ {ready ? ( + + {ready.status === "True" ? "Ready" : (ready.reason ?? "NotReady")} + ) : ( )} @@ -92,6 +75,13 @@ export function TenantsPage() { {formatAge(t.metadata.creationTimestamp)} + + + + + ) })} diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 27a8301..288f41e 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -36,7 +36,7 @@ export function Sidebar({ sections }: SidebarProps) { } return ( -