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/AdditionalPropertiesEditor.tsx b/apps/console/src/components/AdditionalPropertiesEditor.tsx new file mode 100644 index 0000000..9cb5037 --- /dev/null +++ b/apps/console/src/components/AdditionalPropertiesEditor.tsx @@ -0,0 +1,131 @@ +import { useState } from "react" +import Form from "@rjsf/core" +import validator from "@rjsf/validator-ajv8" +import type { RJSFSchema } from "@rjsf/utils" +import { customTemplates, customWidgets } from "./rjsf-templates.tsx" + +interface AdditionalPropertiesEditorProps { + value: Record + onChange: (value: Record) => void + readonly?: boolean + title?: string + description?: string + required?: boolean + itemSchema: RJSFSchema +} + +export function AdditionalPropertiesEditor({ + value, + onChange, + readonly, + title, + description, + required, + itemSchema, +}: AdditionalPropertiesEditorProps) { + const [newKey, setNewKey] = useState("") + + const keys = Object.keys(value || {}) + + const handleAddKey = () => { + if (!newKey.trim()) return + if (value && newKey in value) { + alert(`Key "${newKey}" already exists`) + return + } + + // Initialize with default value from schema + const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" + onChange({ ...value, [newKey]: defaultValue }) + setNewKey("") + } + + const handleRemoveKey = (key: string) => { + const newValue = { ...value } + delete newValue[key] + onChange(newValue) + } + + const handleValueChange = (key: string, newVal: unknown) => { + onChange({ ...value, [key]: newVal }) + } + + return ( +
+ {title && ( + + )} + {description &&

{description}

} + +
+ {keys.map((key) => ( +
+
+
{key}
+ {!readonly && ( + + )} +
+
+
handleValueChange(key, e.formData)} + disabled={readonly} + liveValidate={false} + showErrorList={false} + uiSchema={{ + "ui:submitButtonOptions": { norender: true }, + }} + > + {/* No submit button */} +
+
+
+ ))} + + {keys.length === 0 && readonly && ( +
No entries
+ )} + + {!readonly && ( +
+ setNewKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddKey() + } + }} + placeholder="Enter key name..." + 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" + /> + +
+ )} +
+
+ ) +} diff --git a/apps/console/src/components/AdditionalPropertiesField.tsx b/apps/console/src/components/AdditionalPropertiesField.tsx new file mode 100644 index 0000000..68c78e5 --- /dev/null +++ b/apps/console/src/components/AdditionalPropertiesField.tsx @@ -0,0 +1,126 @@ +import { useState, useMemo } from "react" +import type { FieldProps, RJSFSchema, TemplatesType } from "@rjsf/utils" +import Form from "@rjsf/core" +import validator from "@rjsf/validator-ajv8" +import { customTemplates, customWidgets } from "./rjsf-templates.tsx" + +export function AdditionalPropertiesField(props: FieldProps) { + const { schema, formData, onChange, readonly, disabled, name, required } = props + const [newKey, setNewKey] = useState("") + + // Get the schema for items from additionalProperties + const itemSchema = (schema.additionalProperties as RJSFSchema) || {} + const keys = Object.keys(formData || {}) + + // Create templates without submit button for nested forms + const templatesWithoutSubmit = useMemo>(() => { + return { + ...customTemplates, + ButtonTemplates: { + ...customTemplates.ButtonTemplates, + SubmitButton: () => null, + }, + } + }, []) + + const handleAddKey = () => { + if (!newKey.trim()) return + if (formData && newKey in formData) { + alert(`Key "${newKey}" already exists`) + return + } + + // Initialize with default value from schema + const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" + onChange({ ...formData, [newKey]: defaultValue }) + setNewKey("") + } + + const handleRemoveKey = (key: string) => { + const newValue = { ...formData } + delete newValue[key] + onChange(newValue) + } + + const handleValueChange = (key: string, newVal: unknown) => { + onChange({ ...formData, [key]: newVal }) + } + + const isReadonly = readonly || disabled + + return ( +
+ {name && ( + + )} + {schema.description && ( +

{schema.description}

+ )} + +
+ {keys.map((key) => ( +
+
+
{key}
+ {!isReadonly && ( + + )} +
+
+
handleValueChange(key, e.formData)} + disabled={isReadonly} + liveValidate={false} + showErrorList={false} + /> +
+
+ ))} + + {keys.length === 0 && isReadonly && ( +
No entries
+ )} + + {!isReadonly && ( +
+ setNewKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddKey() + } + }} + placeholder="Enter key name..." + 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" + /> + +
+ )} +
+
+ ) +} diff --git a/apps/console/src/components/AdditionalPropertiesWidget.tsx b/apps/console/src/components/AdditionalPropertiesWidget.tsx new file mode 100644 index 0000000..53fcc14 --- /dev/null +++ b/apps/console/src/components/AdditionalPropertiesWidget.tsx @@ -0,0 +1,120 @@ +import { useState } from "react" +import type { WidgetProps, RJSFSchema } from "@rjsf/utils" +import Form from "@rjsf/core" +import validator from "@rjsf/validator-ajv8" +import { customTemplates, customWidgets } from "./rjsf-templates.tsx" + +export function AdditionalPropertiesWidget(props: WidgetProps) { + const { schema, value, onChange, readonly, disabled, label, required } = props + const [newKey, setNewKey] = useState("") + + // Get the schema for items from additionalProperties + const itemSchema = (schema.additionalProperties as RJSFSchema) || {} + const keys = Object.keys(value || {}) + + const handleAddKey = () => { + if (!newKey.trim()) return + if (value && newKey in value) { + alert(`Key "${newKey}" already exists`) + return + } + + // Initialize with default value from schema + const defaultValue = itemSchema.type === "object" ? {} : itemSchema.default ?? "" + onChange({ ...value, [newKey]: defaultValue }) + setNewKey("") + } + + const handleRemoveKey = (key: string) => { + const newValue = { ...value } + delete newValue[key] + onChange(newValue) + } + + const handleValueChange = (key: string, newVal: unknown) => { + onChange({ ...value, [key]: newVal }) + } + + const isReadonly = readonly || disabled + + return ( +
+ {label && ( + + )} + {schema.description && ( +

{schema.description}

+ )} + +
+ {keys.map((key) => ( +
+
+
{key}
+ {!isReadonly && ( + + )} +
+
+ handleValueChange(key, e.formData)} + disabled={isReadonly} + liveValidate={false} + showErrorList={false} + uiSchema={{ + "ui:submitButtonOptions": { norender: true }, + }} + > + {/* No submit button */} + +
+
+ ))} + + {keys.length === 0 && isReadonly && ( +
No entries
+ )} + + {!isReadonly && ( +
+ setNewKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddKey() + } + }} + placeholder="Enter key name..." + 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" + /> + +
+ )} +
+
+ ) +} diff --git a/apps/console/src/components/CustomObjectFieldTemplate.tsx b/apps/console/src/components/CustomObjectFieldTemplate.tsx new file mode 100644 index 0000000..53d688a --- /dev/null +++ b/apps/console/src/components/CustomObjectFieldTemplate.tsx @@ -0,0 +1,95 @@ +import type { + ObjectFieldTemplateProps, + RJSFSchema, + StrictRJSFSchema, + FormContextType, +} from "@rjsf/utils" +import { KeyValueEditor } from "./KeyValueEditor.tsx" + +export function CustomObjectFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ObjectFieldTemplateProps) { + const { schema, formData, onChange, readonly, disabled } = props + + // Check if this is a free-form key-value object + // ONLY use KeyValueEditor for truly free-form objects where both keys and values are arbitrary + // This means x-kubernetes-preserve-unknown-fields OR additionalProperties: true (boolean, not a schema object) + const isFreeFormObject = + (!schema.properties || Object.keys(schema.properties).length === 0) && + ((schema as any)["x-kubernetes-preserve-unknown-fields"] === true || + schema.additionalProperties === true) + + // If it's a free-form key-value object, use our custom editor + if (isFreeFormObject) { + return ( +
+ {props.title && ( + + )} + {props.description &&

{props.description}

} + +
+ ) + } + + // Check if this is an addon object (has 'enabled' field and other config fields) + const hasEnabledField = props.properties.some((p) => p.name === "enabled") + const hasOtherFields = props.properties.some((p) => p.name !== "enabled") + const isAddon = hasEnabledField && hasOtherFields + + // If this is an addon, use conditional rendering based on 'enabled' state + if (isAddon) { + const isEnabled = formData?.enabled === true + const enabledProp = props.properties.find((p) => p.name === "enabled") + const otherProps = props.properties.filter((p) => p.name !== "enabled") + + return ( +
+ {props.title && ( + {props.title} + )} + {props.description &&

{props.description}

} + + {/* Always show the 'enabled' checkbox */} + {enabledProp && ( +
+ {enabledProp.content} +
+ )} + + {/* Show other fields only if enabled */} + {isEnabled && otherProps.length > 0 && ( +
+ {otherProps.map((prop) => ( +
{prop.content}
+ ))} +
+ )} + + {!isEnabled && otherProps.length > 0 && ( +

+ Enable this addon to configure additional settings +

+ )} +
+ ) + } + + // 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/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/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index ec5c7d9..95ddd6f 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -1,10 +1,78 @@ import { useMemo } from "react" import Form from "@rjsf/core" import validator from "@rjsf/validator-ajv8" -import type { RJSFSchema, UiSchema } from "@rjsf/utils" +import type { RJSFSchema, UiSchema, TemplatesType } from "@rjsf/utils" import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts" +import { customTemplates, customWidgets } from "./rjsf-templates.tsx" +import { AdditionalPropertiesField } from "./AdditionalPropertiesField.tsx" +import { SourceField } from "./SourceField.tsx" import "./schema-form.css" +/** + * Recursively find all storageClass fields in schema and add widget to uiSchema + */ +function addStorageClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { + if (!schema || typeof schema !== "object") return uiSchema + + const properties = (schema as any).properties + if (!properties || typeof properties !== "object") return uiSchema + + const result = { ...uiSchema } + + for (const [key, value] of Object.entries(properties)) { + if (key === "storageClass" && typeof value === "object" && (value as any).type === "string") { + // Found a storageClass field - add widget + result[key] = { + ...result[key], + "ui:widget": "StorageClassWidget", + } + } else if (typeof value === "object" && (value as any).properties) { + // Recursively process nested objects + result[key] = addStorageClassWidgets(value as RJSFSchema, result[key] as UiSchema) + } + } + + return result +} + +/** + * Recursively find all fields with additionalProperties schema and add widget + */ +function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { + if (!schema || typeof schema !== "object") return uiSchema + + const properties = (schema as any).properties + if (!properties || typeof properties !== "object") return uiSchema + + const result = { ...uiSchema } + + for (const [key, value] of Object.entries(properties)) { + if (typeof value === "object" && value !== null) { + const fieldSchema = value as any + // Check if this field has additionalProperties with a schema + const hasAdditionalPropertiesSchema = + fieldSchema.type === "object" && + (!fieldSchema.properties || Object.keys(fieldSchema.properties).length === 0) && + typeof fieldSchema.additionalProperties === "object" && + fieldSchema.additionalProperties !== null && + fieldSchema.additionalProperties !== true + + if (hasAdditionalPropertiesSchema) { + // Found a field with additionalProperties schema - use custom field + result[key] = { + ...result[key], + "ui:field": "AdditionalPropertiesField", + } + } else if (fieldSchema.properties) { + // Recursively process nested objects + result[key] = addAdditionalPropertiesWidgets(fieldSchema, result[key] as UiSchema) + } + } + } + + return result +} + interface SchemaFormProps { openAPISchema: string keysOrder?: string[][] @@ -28,14 +96,42 @@ export function SchemaForm({ } }, [openAPISchema]) - const uiSchema = useMemo( - () => ({ + const uiSchema = useMemo(() => { + const baseUiSchema: UiSchema = { "ui:submitButtonOptions": { norender: true }, ...keysOrderToUiSchema(keysOrder), + // Use SourceField for mutually exclusive source fields + source: { + "ui:field": "SourceField", + }, + } + + // Automatically add StorageClassWidget for all storageClass fields + const withStorageClass = addStorageClassWidgets(schema, baseUiSchema) + + // Automatically add AdditionalPropertiesField for fields with additionalProperties schema + return addAdditionalPropertiesWidgets(schema, withStorageClass) + }, [keysOrder, schema]) + + const customFields = useMemo( + () => ({ + AdditionalPropertiesField: AdditionalPropertiesField, + SourceField: SourceField, }), - [keysOrder], + [] ) + // Create templates without submit button + const templatesWithoutSubmit = useMemo>(() => { + return { + ...customTemplates, + ButtonTemplates: { + ...customTemplates.ButtonTemplates, + SubmitButton: () => null, + }, + } + }, []) + return (
onChange(e.formData)} liveValidate={false} showErrorList={false} diff --git a/apps/console/src/components/SourceField.tsx b/apps/console/src/components/SourceField.tsx new file mode 100644 index 0000000..2079501 --- /dev/null +++ b/apps/console/src/components/SourceField.tsx @@ -0,0 +1,139 @@ +import { useState } from "react" +import type { FieldProps } from "@rjsf/utils" + +export function SourceField(props: FieldProps) { + const { schema, formData, onChange, name, required, idSchema } = props + const properties = (schema as any).properties || {} + const options = Object.keys(properties) + + // Determine which option is currently selected + const currentOption = formData + ? options.find((opt: string) => 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" + ? {} + : "" + 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 confirmation message + if (Object.keys(prop.properties || {}).length === 0) { + return ( +
+

+ ✓ {option} selected +

+

+ {prop.description || "No additional configuration needed"} +

+ {option === "upload" && ( +

+ After creating the disk, you can upload an image using the UI or virtctl command. +

+ )} +
+ ) + } + + // 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]: { + ...(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 ( +
+ + {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/SourceWidget.tsx b/apps/console/src/components/SourceWidget.tsx new file mode 100644 index 0000000..1072554 --- /dev/null +++ b/apps/console/src/components/SourceWidget.tsx @@ -0,0 +1,141 @@ +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 confirmation message + if (Object.keys(prop.properties || {}).length === 0) { + return ( +
+

+ ✓ {option} selected +

+

+ {prop.description || "No additional configuration needed"} +

+ {option === "upload" && ( +

+ After creating the disk, you can upload an image using the UI or virtctl command. +

+ )} +
+ ) + } + + // 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/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx new file mode 100644 index 0000000..ff8d757 --- /dev/null +++ b/apps/console/src/components/StorageClassWidget.tsx @@ -0,0 +1,63 @@ +import { useEffect } 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 +} + +export function StorageClassWidget(props: WidgetProps) { + const { value, onChange, required, disabled, readonly, placeholder } = props + + const { data: scList, isLoading } = useK8sList({ + apiGroup: "storage.k8s.io", + apiVersion: "v1", + plural: "storageclasses", + }) + + const storageClasses = scList?.items || [] + const defaultSC = storageClasses.find( + (sc) => sc.metadata.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" + ) + + // Set default storage class if value is empty and default exists + useEffect(() => { + if (!value && defaultSC && !isLoading) { + onChange(defaultSC.metadata.name) + } + }, [value, defaultSC, isLoading, onChange]) + + return ( + + ) +} diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx new file mode 100644 index 0000000..9522b6e --- /dev/null +++ b/apps/console/src/components/rjsf-templates.tsx @@ -0,0 +1,64 @@ +import type { + IconButtonProps, + TemplatesType, + WidgetsType, + FieldTemplateProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" +import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" +import { SourceWidget } from "./SourceWidget.tsx" +import { StorageClassWidget } from "./StorageClassWidget.tsx" +import { AdditionalPropertiesWidget } from "./AdditionalPropertiesWidget.tsx" +import { AdditionalPropertiesField } from "./AdditionalPropertiesField.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, + StorageClassWidget: StorageClassWidget, + AdditionalPropertiesWidget: AdditionalPropertiesWidget, +} 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/apps/console/src/lib/keys-order.ts b/apps/console/src/lib/keys-order.ts index 7dbe522..cc64a37 100644 --- a/apps/console/src/lib/keys-order.ts +++ b/apps/console/src/lib/keys-order.ts @@ -80,5 +80,11 @@ export function sanitizeSchema(schema: any): any { out.type = "object" out.additionalProperties = true } + + // Replace "Chart Values" title with "Parameters" + if (out.title === "Chart Values") { + out.title = "Parameters" + } + return out } diff --git a/apps/console/src/lib/use-crd-schema.ts b/apps/console/src/lib/use-crd-schema.ts new file mode 100644 index 0000000..b869bfb --- /dev/null +++ b/apps/console/src/lib/use-crd-schema.ts @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query" +import { useK8sGet } from "@cozystack/k8s-client" + +interface CRDVersion { + name: string + schema?: { + openAPIV3Schema?: { + properties?: { + spec?: any + } + } + } +} + +interface CRD { + apiVersion: string + kind: string + metadata: { + name: string + } + spec: { + group: string + versions: CRDVersion[] + } +} + +/** + * Hook to fetch OpenAPI schema from a CRD's spec field + */ +export function useCRDSchema(crdName: string) { + const { data: crd, isLoading, error } = useK8sGet( + { + apiGroup: "apiextensions.k8s.io", + apiVersion: "v1", + plural: "customresourcedefinitions", + name: crdName, + }, + { enabled: !!crdName }, + ) + + // Extract the schema from the first version's spec field + const schema = crd?.spec?.versions?.[0]?.schema?.openAPIV3Schema?.properties?.spec + + return { + schema: schema ? JSON.stringify(schema) : null, + isLoading, + error, + } +} diff --git a/apps/console/src/routes/ApplicationListPage.tsx b/apps/console/src/routes/ApplicationListPage.tsx index 85fee33..83049fc 100644 --- a/apps/console/src/routes/ApplicationListPage.tsx +++ b/apps/console/src/routes/ApplicationListPage.tsx @@ -62,7 +62,7 @@ export function ApplicationListPage() {

- + @@ -77,7 +77,7 @@ export function ApplicationListPage() {

No {pluralLabel.toLowerCase()} yet.

- + diff --git a/apps/console/src/routes/BackupCreatePage.tsx b/apps/console/src/routes/BackupCreatePage.tsx new file mode 100644 index 0000000..d688005 --- /dev/null +++ b/apps/console/src/routes/BackupCreatePage.tsx @@ -0,0 +1,240 @@ +import { useState, useMemo } from "react" +import { useNavigate } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate, useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +/** + * Recursively adds enum values to schema properties + */ +function enrichSchemaWithEnums( + schema: any, + path: string[], + enumMap: Record +): any { + if (!schema || typeof schema !== "object") return schema + + const currentPath = path.join(".") + const result = { ...schema } + + // Add enum if this path has enum values + if (enumMap[currentPath]) { + result.enum = enumMap[currentPath] + } + + // Recurse into properties + if (result.properties) { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, value]) => [ + key, + enrichSchemaWithEnums(value, [...path, key], enumMap), + ]) + ) + } + + return result +} + +export function BackupCreatePage() { + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const { data: appDefs } = useApplicationDefinitions() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + // Get base schema from CRD + const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( + "backups.backups.cozystack.io" + ) + + // Get instances for selected kind + const selectedKind = formData?.applicationRef?.kind + const selectedAppDef = useMemo( + () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), + [appDefs, selectedKind] + ) + + const { data: instancesData } = useK8sList({ + apiGroup: selectedAppDef?.spec?.application.group ?? "apps.cozystack.io", + apiVersion: selectedAppDef?.spec?.application.version ?? "v1alpha1", + plural: selectedAppDef?.spec?.application.plural ?? "", + namespace: tenantNamespace ?? "", + }, { enabled: !!selectedAppDef && !!tenantNamespace }) + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backups", + namespace: tenantNamespace ?? "", + }) + + const schema = useMemo(() => { + if (!baseSchema) return null + + const base = JSON.parse(baseSchema) + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] + + const enumMap: Record = {} + + // Add enum values for dropdowns + if (kinds.length > 0) { + enumMap["applicationRef.kind"] = kinds + } + if (selectedKind && instances.length > 0) { + enumMap["applicationRef.name"] = instances + } + + // Enrich schema with enum values + const enriched = enrichSchemaWithEnums(base, [], enumMap) + + // Add default values for apiGroup fields + if (enriched.properties?.applicationRef?.properties?.apiGroup) { + enriched.properties.applicationRef.properties.apiGroup.default = "apps.cozystack.io" + } + if (enriched.properties?.strategyRef?.properties?.apiGroup) { + enriched.properties.strategyRef.properties.apiGroup.default = "strategy.backups.cozystack.io" + } + + return JSON.stringify(enriched) + }, [baseSchema, appDefs, instancesData, selectedKind]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + alert("Name is required") + return + } + + if (!formData.applicationRef?.kind || !formData.applicationRef?.name) { + alert("Application reference is required") + return + } + + if (!formData.strategyRef?.kind || !formData.strategyRef?.name) { + alert("Strategy reference is required") + return + } + + if (!formData.takenAt) { + alert("Taken at timestamp is required") + return + } + + const resource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: "Backup", + metadata: { + name: name.trim(), + namespace: tenantNamespace, + }, + spec: formData, + } + + try { + await createMutation.mutateAsync(resource) + navigate("/console/backups/backups") + } catch (err) { + alert(`Failed to create Backup: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate("/console/backups/backups") + } + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + if (!schema) { + return ( +
+ Failed to load Backup schema. Please refresh the page. +
+ ) + } + + return ( +
+
+
+ +
+
+

Create Backup

+

+ Create a backup snapshot for an application +

+
+
+ + +
+
+
+ + setName(e.target.value)} + placeholder="my-backup" + className="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" + required + /> +
+ +
+ +
+ +
+
+ +
+ + +
+
+ +
+ ) +} diff --git a/apps/console/src/routes/BackupPlanCreatePage.tsx b/apps/console/src/routes/BackupPlanCreatePage.tsx new file mode 100644 index 0000000..1b228b2 --- /dev/null +++ b/apps/console/src/routes/BackupPlanCreatePage.tsx @@ -0,0 +1,243 @@ +import { useState, useMemo } from "react" +import { useNavigate } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate, useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +/** + * Recursively adds enum values to schema properties + */ +function enrichSchemaWithEnums( + schema: any, + path: string[], + enumMap: Record +): any { + if (!schema || typeof schema !== "object") return schema + + const currentPath = path.join(".") + const result = { ...schema } + + // Add enum if this path has enum values + if (enumMap[currentPath]) { + result.enum = enumMap[currentPath] + } + + // Recurse into properties + if (result.properties) { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, value]) => [ + key, + enrichSchemaWithEnums(value, [...path, key], enumMap), + ]) + ) + } + + return result +} + +export function BackupPlanCreatePage() { + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const { data: appDefs } = useApplicationDefinitions() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + // Get base schema from CRD + const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( + "plans.backups.cozystack.io" + ) + + // Get BackupClasses + const { data: backupClassesData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }) + + // Get instances for selected kind + const selectedKind = formData?.applicationRef?.kind + const selectedAppDef = useMemo( + () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), + [appDefs, selectedKind] + ) + + const { data: instancesData } = useK8sList({ + apiGroup: selectedAppDef?.spec?.application.group ?? "apps.cozystack.io", + apiVersion: selectedAppDef?.spec?.application.version ?? "v1alpha1", + plural: selectedAppDef?.spec?.application.plural ?? "", + namespace: tenantNamespace ?? "", + }, { enabled: !!selectedAppDef && !!tenantNamespace }) + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "plans", + namespace: tenantNamespace ?? "", + }) + + const schema = useMemo(() => { + if (!baseSchema) return null + + const base = JSON.parse(baseSchema) + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] + const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] + + const enumMap: Record = {} + + // Add enum values for dropdowns + if (kinds.length > 0) { + enumMap["applicationRef.kind"] = kinds + } + if (selectedKind && instances.length > 0) { + enumMap["applicationRef.name"] = instances + } + if (backupClasses.length > 0) { + enumMap["backupClassName"] = backupClasses + } + + // Enrich schema with enum values + const enriched = enrichSchemaWithEnums(base, [], enumMap) + + // Add default value for apiGroup + if (enriched.properties?.applicationRef?.properties?.apiGroup) { + enriched.properties.applicationRef.properties.apiGroup.default = "apps.cozystack.io" + } + + return JSON.stringify(enriched) + }, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + alert("Name is required") + return + } + + if (!formData.applicationRef?.kind || !formData.applicationRef?.name) { + alert("Application reference is required") + return + } + + if (!formData.backupClassName) { + alert("Backup class name is required") + return + } + + const resource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: "Plan", + metadata: { + name: name.trim(), + namespace: tenantNamespace, + }, + spec: formData, + } + + try { + await createMutation.mutateAsync(resource) + navigate("/console/backups/plans") + } catch (err) { + alert(`Failed to create Plan: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate("/console/backups/plans") + } + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + if (!schema) { + return ( +
+ Failed to load Plan schema. Please refresh the page. +
+ ) + } + + return ( +
+
+
+ +
+
+

Create Plan

+

+ Configure a backup plan for your application +

+
+
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="my-backup-plan" + className="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" + required + /> +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupResourceCreatePage.tsx b/apps/console/src/routes/BackupResourceCreatePage.tsx new file mode 100644 index 0000000..4baac34 --- /dev/null +++ b/apps/console/src/routes/BackupResourceCreatePage.tsx @@ -0,0 +1,161 @@ +import { useState } from "react" +import { useNavigate } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +interface BackupResourceCreatePageProps { + resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" + title: string + overrideSchema?: string // Optional schema override (e.g., with enum values) +} + +export function BackupResourceCreatePage({ + resourceType, + title, + overrideSchema, +}: BackupResourceCreatePageProps) { + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + // Map resourceType to CRD name + const crdNameMap = { + plans: "plans.backups.cozystack.io", + backupjobs: "backupjobs.backups.cozystack.io", + backups: "backups.backups.cozystack.io", + restorejobs: "restorejobs.backups.cozystack.io", + } + + const { schema: crdSchema, isLoading: schemaLoading } = useCRDSchema(crdNameMap[resourceType]) + + // Use override schema if provided, otherwise use CRD schema + const schema = overrideSchema || crdSchema + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: resourceType, + namespace: tenantNamespace ?? "", + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + alert("Name is required") + return + } + + const resource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: title.slice(0, -1), // Remove 's' from plural title + metadata: { + name: name.trim(), + namespace: tenantNamespace, + }, + spec: formData, + } + + try { + await createMutation.mutateAsync(resource) + navigate(`/console/backups/${resourceType}`) + } catch (err) { + alert(`Failed to create ${title}: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate(`/console/backups/${resourceType}`) + } + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + return ( +
+
+
+ +
+
+

+ Create {title.slice(0, -1)} +

+

+ Fill in the details to create a new {title.toLowerCase().slice(0, -1)} +

+
+
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="my-resource-name" + className="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" + required + /> +
+ + {schema && ( +
+ +
+ +
+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupResourceCreatePageWithData.tsx b/apps/console/src/routes/BackupResourceCreatePageWithData.tsx new file mode 100644 index 0000000..015c996 --- /dev/null +++ b/apps/console/src/routes/BackupResourceCreatePageWithData.tsx @@ -0,0 +1,156 @@ +import { useMemo } from "react" +import { useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { BackupResourceCreatePage } from "./BackupResourceCreatePage.tsx" + +interface BackupResourceCreatePageWithDataProps { + resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" + title: string +} + +/** + * Recursively adds enum values to schema properties + */ +function enrichSchemaWithEnums( + schema: any, + path: string[], + enumMap: Record +): any { + if (!schema || typeof schema !== "object") return schema + + const currentPath = path.join(".") + const result = { ...schema } + + // Add enum if this path has enum values + if (enumMap[currentPath]) { + result.enum = enumMap[currentPath] + } + + // Recurse into properties + if (result.properties) { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, value]) => [ + key, + enrichSchemaWithEnums(value, [...path, key], enumMap), + ]) + ) + } + + return result +} + +export function BackupResourceCreatePageWithData({ + resourceType, + title, +}: BackupResourceCreatePageWithDataProps) { + const { tenantNamespace } = useTenantContext() + const { data: appDefs } = useApplicationDefinitions() + + // Map resourceType to CRD name + const crdNameMap = { + plans: "plans.backups.cozystack.io", + backupjobs: "backupjobs.backups.cozystack.io", + backups: "backups.backups.cozystack.io", + restorejobs: "restorejobs.backups.cozystack.io", + } + + const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( + crdNameMap[resourceType] + ) + + // Get Plans + const { data: plansData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "plans", + namespace: tenantNamespace ?? "", + }, { enabled: !!tenantNamespace && (resourceType === "backupjobs" || resourceType === "restorejobs") }) + + // Get Backups + const { data: backupsData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backups", + namespace: tenantNamespace ?? "", + }, { enabled: !!tenantNamespace && resourceType === "restorejobs" }) + + // Get BackupClasses + const { data: backupClassesData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backupclasses", + }, { enabled: resourceType === "plans" }) + + const enrichedSchema = useMemo(() => { + if (!baseSchema) return null + + const base = JSON.parse(baseSchema) + const enumMap: Record = {} + + // Add enum values based on resource type + if (resourceType === "plans") { + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] + + if (kinds.length > 0) { + enumMap["applicationRef.kind"] = kinds + } + if (backupClasses.length > 0) { + enumMap["backupClassName"] = backupClasses + } + } + + if (resourceType === "backupjobs") { + const plans = plansData?.items.map((p: any) => p.metadata.name) ?? [] + if (plans.length > 0) { + enumMap["planRef.name"] = plans + } + } + + if (resourceType === "backups") { + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + const strategies = [] // TODO: Get from BackupStrategy resources if needed + + if (kinds.length > 0) { + enumMap["applicationRef.kind"] = kinds + } + if (strategies.length > 0) { + enumMap["strategyRef.name"] = strategies + } + } + + if (resourceType === "restorejobs") { + const backups = backupsData?.items.map((b: any) => b.metadata.name) ?? [] + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + + if (backups.length > 0) { + enumMap["backupRef.name"] = backups + } + if (kinds.length > 0) { + enumMap["targetRef.kind"] = kinds + } + } + + // Enrich schema with enum values + const enriched = enrichSchemaWithEnums(base, [], enumMap) + return JSON.stringify(enriched) + }, [baseSchema, resourceType, appDefs, plansData, backupsData, backupClassesData]) + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + return ( + + ) +} diff --git a/apps/console/src/routes/BackupResourceEditPage.tsx b/apps/console/src/routes/BackupResourceEditPage.tsx new file mode 100644 index 0000000..44b1336 --- /dev/null +++ b/apps/console/src/routes/BackupResourceEditPage.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from "react" +import { useNavigate, useParams } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sGet, useK8sUpdate } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +interface BackupResourceEditPageProps { + resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" + title: string + overrideSchema?: string +} + +export function BackupResourceEditPage({ + resourceType, + title, + overrideSchema, +}: BackupResourceEditPageProps) { + const { name } = useParams<{ name: string }>() + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const [formData, setFormData] = useState({}) + + // Map resourceType to CRD name + const crdNameMap = { + plans: "plans.backups.cozystack.io", + backupjobs: "backupjobs.backups.cozystack.io", + backups: "backups.backups.cozystack.io", + restorejobs: "restorejobs.backups.cozystack.io", + } + + const { schema: crdSchema, isLoading: schemaLoading } = useCRDSchema(crdNameMap[resourceType]) + + // Use override schema if provided, otherwise use CRD schema + const schema = overrideSchema || crdSchema + + // Fetch existing resource + const { data: resource, isLoading: resourceLoading, error } = useK8sGet( + { + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: resourceType, + name: name ?? "", + namespace: tenantNamespace ?? "", + }, + { enabled: !!name && !!tenantNamespace }, + ) + + const updateMutation = useK8sUpdate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: resourceType, + namespace: tenantNamespace ?? "", + }) + + // Initialize form data from resource + useEffect(() => { + if (resource?.spec) { + setFormData(resource.spec) + } + }, [resource]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!resource) return + + const updated = { + ...resource, + spec: formData, + } + + try { + await updateMutation.mutateAsync({ name: name!, resource: updated }) + navigate(`/console/backups/${resourceType}`) + } catch (err) { + alert(`Failed to update ${title.slice(0, -1)}: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate(`/console/backups/${resourceType}`) + } + + if (schemaLoading || resourceLoading) { + return ( +
+ Loading... +
+ ) + } + + if (error) { + return ( +
+ Failed to load {title.slice(0, -1)}: {(error as Error).message} +
+ ) + } + + if (!resource) { + return ( +
+ {title.slice(0, -1)} not found. +
+ ) + } + + return ( +
+
+
+ +
+
+

+ Edit {title.slice(0, -1)} +

+

+ {name} +

+
+
+ +
+
+
+ {schema && ( +
+ +
+ +
+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/console/src/routes/BackupResourceListPage.tsx b/apps/console/src/routes/BackupResourceListPage.tsx new file mode 100644 index 0000000..0716a56 --- /dev/null +++ b/apps/console/src/routes/BackupResourceListPage.tsx @@ -0,0 +1,184 @@ +import { Link, useNavigate } from "react-router" +import { Archive, Plus, Edit, Trash2 } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sList, useK8sDelete } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { formatAge } from "../lib/status.ts" + +interface BackupResource { + metadata: { + name: string + namespace?: string + creationTimestamp?: string + } + status?: { + phase?: string + conditions?: Array<{ + type: string + status: string + reason?: string + message?: string + }> + } +} + +interface BackupResourceListPageProps { + resourceType: "plans" | "backupjobs" | "backups" | "restorejobs" + title: string +} + +export function BackupResourceListPage({ resourceType, title }: BackupResourceListPageProps) { + const { tenantNamespace } = useTenantContext() + const navigate = useNavigate() + + const { data, isLoading, error, refetch } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: resourceType, + namespace: tenantNamespace ?? "", + }, { enabled: !!tenantNamespace }) + + const deleteMutation = useK8sDelete({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: resourceType, + namespace: tenantNamespace ?? "", + }) + + const items = data?.items ?? [] + + const handleDelete = async (name: string) => { + const singularTitle = title.slice(0, -1) // Remove 's' from plural + if (!confirm(`Delete ${singularTitle} "${name}"? This cannot be undone.`)) return + + try { + await deleteMutation.mutateAsync(name) + refetch() + } catch (err) { + alert(`Failed to delete: ${(err as Error).message}`) + } + } + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (error) { + return ( +
+ Failed to load {title}: {(error as Error).message} +
+ ) + } + + return ( +
+
+
+
+ +
+
+

{title}

+

+ {items.length} {items.length === 1 ? "item" : "items"} +

+
+
+ + + +
+ +
+ {items.length === 0 ? ( +
+ No {title.toLowerCase()} found in this namespace. +
+ ) : ( +
+ + + + + + + + + + + + {items.map((item) => { + const phase = item.status?.phase + const ready = item.status?.conditions?.find((c) => c.type === "Ready") + const statusText = phase || ready?.status || "Unknown" + const statusTone = + statusText === "Completed" || statusText === "True" ? "ok" : + statusText === "Failed" || statusText === "False" ? "error" : + "warn" + + return ( + + + + + + + + ) + })} + +
NameNamespaceStatusAgeActions
+ {item.metadata.name} + + {item.metadata.namespace} + + + {statusText} + + + {item.metadata.creationTimestamp + ? formatAge(item.metadata.creationTimestamp) + : "-"} + +
+ + + + +
+
+
+ )} +
+
+ ) +} diff --git a/apps/console/src/routes/BackupRestoreJobCreatePage.tsx b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx new file mode 100644 index 0000000..8b515d7 --- /dev/null +++ b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx @@ -0,0 +1,245 @@ +import { useState, useMemo } from "react" +import { useNavigate } from "react-router" +import { Archive, Save } from "lucide-react" +import { Button, Section, Spinner } from "@cozystack/ui" +import { useK8sCreate, useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useCRDSchema } from "../lib/use-crd-schema.ts" +import { SchemaForm } from "../components/SchemaForm.tsx" + +/** + * Recursively adds enum values to schema properties + */ +function enrichSchemaWithEnums( + schema: any, + path: string[], + enumMap: Record +): any { + if (!schema || typeof schema !== "object") return schema + + const currentPath = path.join(".") + const result = { ...schema } + + // Add enum if this path has enum values + if (enumMap[currentPath]) { + result.enum = enumMap[currentPath] + } + + // Recurse into properties + if (result.properties) { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, value]) => [ + key, + enrichSchemaWithEnums(value, [...path, key], enumMap), + ]) + ) + } + + return result +} + +export function BackupRestoreJobCreatePage() { + const navigate = useNavigate() + const { tenantNamespace } = useTenantContext() + const { data: appDefs } = useApplicationDefinitions() + const [formData, setFormData] = useState({}) + const [name, setName] = useState("") + + // Get base schema from CRD + const { schema: baseSchema, isLoading: schemaLoading } = useCRDSchema( + "restorejobs.backups.cozystack.io" + ) + + // Get Backups + const { data: backupsData } = useK8sList({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "backups", + namespace: tenantNamespace ?? "", + }, { enabled: !!tenantNamespace }) + + // Get instances for selected target kind + const selectedKind = formData?.targetRef?.kind + const selectedAppDef = useMemo( + () => appDefs?.items.find(d => d.spec?.application.kind === selectedKind), + [appDefs, selectedKind] + ) + + const { data: instancesData } = useK8sList({ + apiGroup: selectedAppDef?.spec?.application.group ?? "apps.cozystack.io", + apiVersion: selectedAppDef?.spec?.application.version ?? "v1alpha1", + plural: selectedAppDef?.spec?.application.plural ?? "", + namespace: tenantNamespace ?? "", + }, { enabled: !!selectedAppDef && !!tenantNamespace }) + + const createMutation = useK8sCreate({ + apiGroup: "backups.cozystack.io", + apiVersion: "v1alpha1", + plural: "restorejobs", + namespace: tenantNamespace ?? "", + }) + + const schema = useMemo(() => { + if (!baseSchema) return null + + const base = JSON.parse(baseSchema) + const backups = backupsData?.items.map((b: any) => b.metadata.name) ?? [] + const kinds = appDefs?.items.map(d => d.spec?.application.kind).filter(Boolean) ?? [] + const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] + + const enumMap: Record = {} + + // Add enum values for dropdowns + if (backups.length > 0) { + enumMap["backupRef.name"] = backups + } + if (kinds.length > 0) { + enumMap["targetRef.kind"] = kinds + } + // Add instances enum only after kind is selected + if (selectedKind && instances.length > 0) { + enumMap["targetRef.name"] = instances + } + + // Enrich schema with enum values + const enriched = enrichSchemaWithEnums(base, [], enumMap) + + // Add default value for apiGroup + if (enriched.properties?.targetRef?.properties?.apiGroup) { + enriched.properties.targetRef.properties.apiGroup.default = "apps.cozystack.io" + } + + return JSON.stringify(enriched) + }, [baseSchema, backupsData, appDefs, instancesData, selectedKind]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + alert("Name is required") + return + } + + if (!formData.backupRef?.name) { + alert("Backup reference is required") + return + } + + if (!formData.targetRef?.kind || !formData.targetRef?.name) { + alert("Target reference is required") + return + } + + const resource = { + apiVersion: "backups.cozystack.io/v1alpha1", + kind: "RestoreJob", + metadata: { + name: name.trim(), + namespace: tenantNamespace, + }, + spec: formData, + } + + try { + await createMutation.mutateAsync(resource) + navigate("/console/backups/restorejobs") + } catch (err) { + alert(`Failed to create RestoreJob: ${(err as Error).message}`) + } + } + + const handleCancel = () => { + navigate("/console/backups/restorejobs") + } + + if (schemaLoading) { + return ( +
+ Loading schema... +
+ ) + } + + if (!schema) { + return ( +
+ Failed to load RestoreJob schema. Please refresh the page. +
+ ) + } + + return ( +
+
+
+ +
+
+

Create Restore Job

+

+ Restore a backup to an application instance +

+
+
+ +
+
+
+
+ + setName(e.target.value)} + placeholder="my-restore-job" + className="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" + required + /> +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/console/src/routes/ConsolePage.tsx b/apps/console/src/routes/ConsolePage.tsx index d1f7332..9a04a56 100644 --- a/apps/console/src/routes/ConsolePage.tsx +++ b/apps/console/src/routes/ConsolePage.tsx @@ -7,6 +7,13 @@ import { InfoRedirect } from "./InfoRedirect.tsx" import { ApplicationListPage } from "./ApplicationListPage.tsx" import { ApplicationDetailPage } from "./detail/ApplicationDetailPage.tsx" import { ApplicationEditRoute } from "./detail/ApplicationEditRoute.tsx" +import { BackupResourceListPage } from "./BackupResourceListPage.tsx" +import { BackupResourceCreatePageWithData } from "./BackupResourceCreatePageWithData.tsx" +import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx" +import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx" +import { BackupCreatePage } from "./BackupCreatePage.tsx" +import { BackupRestoreJobCreatePage } from "./BackupRestoreJobCreatePage.tsx" +import { ApplicationOrderPage } from "./ApplicationOrderPage.tsx" export function ConsolePage() { return ( @@ -16,6 +23,55 @@ export function ConsolePage() { } /> } /> } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> } /> } /> } /> diff --git a/apps/console/src/routes/TenantsPage.tsx b/apps/console/src/routes/TenantsPage.tsx index 795aef0..7be1f65 100644 --- a/apps/console/src/routes/TenantsPage.tsx +++ b/apps/console/src/routes/TenantsPage.tsx @@ -1,20 +1,20 @@ +import { useMemo } from "react" import { Link } from "react-router" -import { Plus } from "lucide-react" +import { Plus, Edit } from "lucide-react" import { Spinner, Section, Button } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" import type { TenantNamespace } from "@cozystack/types" import { tenantDisplayName, useTenantContext } from "../lib/tenant-context.tsx" import { formatAge } 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" }, -] - -function enabledModules(ns: TenantNamespace): string[] { - const labels = ns.metadata.labels ?? {} - return MODULE_LABELS.filter((m) => labels[m.key] != null).map((m) => m.label) +interface TenantModule { + metadata: { + name: string + namespace: string + } + status?: { + ready?: boolean + } } function tenantHost(ns: TenantNamespace): string | undefined { @@ -24,6 +24,31 @@ function tenantHost(ns: TenantNamespace): string | undefined { export function TenantsPage() { const { tenants, isLoading } = useTenantContext() + // Get all TenantModules across all namespaces + const { data: modulesData } = useK8sList({ + apiGroup: "core.cozystack.io", + apiVersion: "v1alpha1", + plural: "tenantmodules", + }) + + // Group modules by namespace + const modulesByNamespace = useMemo(() => { + const map = new Map() + modulesData?.items.forEach((mod) => { + const ns = mod.metadata.namespace + if (!map.has(ns)) { + map.set(ns, []) + } + // Only include info module if it's not the only module (info is default) + if (mod.metadata.name !== "info" || (modulesData?.items.filter(m => m.metadata.namespace === ns).length ?? 0) > 1) { + if (mod.metadata.name !== "info") { + map.get(ns)!.push(mod.metadata.name) + } + } + }) + return map + }, [modulesData]) + return (
@@ -57,12 +82,13 @@ export function TenantsPage() { Host Modules Age + {tenants.map((t) => { const name = tenantDisplayName(t) - const modules = enabledModules(t) + const modules = modulesByNamespace.get(t.metadata.name) ?? [] const host = tenantHost(t) return ( @@ -92,6 +118,13 @@ export function TenantsPage() { {formatAge(t.metadata.creationTimestamp)} + + + + + ) })} diff --git a/apps/console/src/routes/backup-schemas.ts b/apps/console/src/routes/backup-schemas.ts new file mode 100644 index 0000000..d28ff3c --- /dev/null +++ b/apps/console/src/routes/backup-schemas.ts @@ -0,0 +1,139 @@ +// Simplified OpenAPI schemas for backup resources +// These are based on the CRD definitions from backups.cozystack.io/v1alpha1 + +export const planSchema = JSON.stringify({ + type: "object", + required: ["applicationRef", "backupClassName", "schedule"], + properties: { + applicationRef: { + type: "object", + title: "Application Reference", + description: "Reference to the application to backup", + required: ["kind", "name"], + properties: { + apiGroup: { + type: "string", + title: "API Group", + default: "apps.cozystack.io", + }, + kind: { + type: "string", + title: "Kind", + description: "Type of resource (e.g., Postgres, MySQL)", + }, + name: { + type: "string", + title: "Name", + description: "Name of the resource instance", + }, + }, + }, + backupClassName: { + type: "string", + title: "Backup Class Name", + description: "Name of the BackupClass to use", + }, + schedule: { + type: "object", + title: "Schedule", + description: "Backup schedule configuration", + properties: { + type: { + type: "string", + title: "Type", + default: "cron", + enum: ["cron"], + }, + cron: { + type: "string", + title: "Cron Expression", + description: "Cron schedule (e.g., '0 2 * * *' for daily at 2am)", + default: "0 2 * * *", + }, + }, + }, + }, +}) + +export const backupJobSchema = JSON.stringify({ + type: "object", + required: ["planRef"], + properties: { + planRef: { + type: "object", + title: "Plan Reference", + description: "Reference to the backup plan", + required: ["name"], + properties: { + name: { + type: "string", + title: "Plan Name", + description: "Name of the Plan resource", + }, + }, + }, + }, +}) + +export const backupSchema = JSON.stringify({ + type: "object", + required: ["planRef"], + properties: { + planRef: { + type: "object", + title: "Plan Reference", + description: "Reference to the backup plan", + required: ["name"], + properties: { + name: { + type: "string", + title: "Plan Name", + description: "Name of the Plan resource", + }, + }, + }, + }, +}) + +export const restoreJobSchema = JSON.stringify({ + type: "object", + required: ["backupRef", "targetRef"], + properties: { + backupRef: { + type: "object", + title: "Backup Reference", + description: "Reference to the backup to restore from", + required: ["name"], + properties: { + name: { + type: "string", + title: "Backup Name", + description: "Name of the Backup resource", + }, + }, + }, + targetRef: { + type: "object", + title: "Target Reference", + description: "Reference to the application to restore to", + required: ["kind", "name"], + properties: { + apiGroup: { + type: "string", + title: "API Group", + default: "apps.cozystack.io", + }, + kind: { + type: "string", + title: "Kind", + description: "Type of resource (e.g., Postgres, MySQL)", + }, + name: { + type: "string", + title: "Name", + description: "Name of the resource instance", + }, + }, + }, + }, +}) diff --git a/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/apps/console/src/routes/detail/ApplicationDetailPage.tsx index e7cd537..8e0dc3f 100644 --- a/apps/console/src/routes/detail/ApplicationDetailPage.tsx +++ b/apps/console/src/routes/detail/ApplicationDetailPage.tsx @@ -53,7 +53,10 @@ export function ApplicationDetailPage() { name: name ?? "", namespace: tenantNamespace ?? "", }, - { enabled: !!plural && !!name && !!tenantNamespace }, + { + enabled: !!plural && !!name && !!tenantNamespace, + refetchInterval: 5000, // Auto-refresh every 5 seconds + }, ) const del = useK8sDelete({ @@ -83,7 +86,7 @@ export function ApplicationDetailPage() { if (!confirm(`Delete ${appDisplayName(ad)} "${name}"? This cannot be undone.`)) return try { await del.mutateAsync(name) - navigate("/console") + navigate(`/console/${plural}`) } catch (err) { alert((err as Error).message) } @@ -92,17 +95,30 @@ export function ApplicationDetailPage() { const ready = readyCondition(instance) const icon = iconDataUrl(ad) const base = `/console/${plural}/${name}` + const kind = ad.spec?.application.kind + // Absolute URLs so NavLink always rewrites the whole "///..." // suffix instead of appending to the current tab path. - const tabs = [ - { to: base, label: "Overview", end: true }, - { to: `${base}/workloads`, label: "Workloads" }, - { to: `${base}/services`, label: "Services" }, - { to: `${base}/ingresses`, label: "Ingresses" }, - { to: `${base}/secrets`, label: "Secrets" }, - ] - if (ad.spec?.application.kind === "VMInstance") { - tabs.push({ to: `${base}/vnc`, label: "VNC" }) + const tabs = [{ to: base, label: "Overview", end: true }] + + // Different tab sets for different resource types + if (kind === "VMDisk") { + // VMDisk: storage-only resource, no workloads/services/ingresses/secrets + } else if (kind === "VMInstance") { + // VMInstance: VM-specific tabs (no ingresses/secrets) + tabs.push( + { to: `${base}/workloads`, label: "Workloads" }, + { to: `${base}/services`, label: "Services" }, + { to: `${base}/vnc`, label: "VNC" }, + ) + } else { + // Other resources: full tab set + tabs.push( + { to: `${base}/workloads`, label: "Workloads" }, + { to: `${base}/services`, label: "Services" }, + { to: `${base}/ingresses`, label: "Ingresses" }, + { to: `${base}/secrets`, label: "Secrets" }, + ) } return ( diff --git a/apps/console/src/routes/detail/SecretsTab.tsx b/apps/console/src/routes/detail/SecretsTab.tsx index c0ce2f8..c7d1f3b 100644 --- a/apps/console/src/routes/detail/SecretsTab.tsx +++ b/apps/console/src/routes/detail/SecretsTab.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { Eye, EyeOff, Copy } from "lucide-react" +import { Eye, EyeOff, Copy, ChevronDown, ChevronRight } from "lucide-react" import { useK8sGet, useK8sList, @@ -10,10 +10,10 @@ import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/type import { appInstanceLabel } from "../../lib/labels.ts" import { formatAge } from "../../lib/status.ts" -const TENANT_SECRETS_REF = { - apiGroup: "core.cozystack.io", - apiVersion: "v1alpha1", - plural: "tenantsecrets", +const SECRETS_REF = { + apiGroup: "", + apiVersion: "v1", + plural: "secrets", } interface SecretLike { @@ -36,31 +36,53 @@ function SecretRow({ name, keyName, base64Value, + forceReveal, }: { namespace: string name: string keyName: string base64Value: string + forceReveal?: boolean }) { const [revealed, setRevealed] = useState(false) + const [expanded, setExpanded] = useState(false) + const shouldReveal = forceReveal || revealed const { data } = useK8sGet & SecretLike>( - { ...TENANT_SECRETS_REF, namespace, name }, - { enabled: revealed }, + { ...SECRETS_REF, namespace, name }, + { enabled: shouldReveal }, ) - const fullValue = revealed + const fullValue = shouldReveal ? decodeValue(data?.data?.[keyName]) || data?.stringData?.[keyName] || decodeValue(base64Value) : "" + + const isLarge = fullValue.split('\n').length > 5 || fullValue.length > 200 + return ( -
- {keyName} -
- {revealed ? ( - {fullValue || "(empty)"} +
+ {keyName} +
+ {shouldReveal ? ( + <> +
+              {fullValue || "(empty)"}
+            
+ {isLarge && ( + + )} + ) : ( •••••••• )}
-
+
+
+ + + {formatAge(secret.metadata.creationTimestamp)} + +
+
+ {isExpanded && keys.length > 0 && ( +
+ {keys.map((k) => ( + + ))} +
+ )} + + ) +} + export function SecretsTab({ ad, instance, @@ -90,56 +177,46 @@ export function SecretsTab({ ad: ApplicationDefinition instance: ApplicationInstance }) { - const ns = instance.metadata.namespace ?? "" + const appKind = ad.spec?.application.kind + const isTenant = appKind === "Tenant" + + // For Tenant, use tenant namespace from status and TenantSecret API + // For other apps, use regular secrets with application labels + const ns = isTenant + ? (instance.status as any)?.namespace ?? instance.metadata.namespace ?? "" + : instance.metadata.namespace ?? "" + + const apiRef = isTenant + ? { + apiGroup: "core.cozystack.io", + apiVersion: "v1alpha1", + plural: "tenantsecrets", + } + : SECRETS_REF + + const labelSelector = isTenant + ? undefined // For Tenant, show all secrets in tenant namespace + : appInstanceLabel(ad, instance) + const { data, isLoading } = useK8sList( - { ...TENANT_SECRETS_REF, namespace: ns }, - { labelSelector: appInstanceLabel(ad, instance) }, + { ...apiRef, namespace: ns }, + { labelSelector }, ) const items = data?.items ?? [] return (
-
+
{isLoading ? (
Loading…
) : items.length === 0 ? ( -
No tenant secrets.
+
No secrets.
) : (
    - {items.map((sec) => { - const keys = Object.keys(sec.data ?? {}) - return ( -
  • -
    -
    -

    - {sec.metadata.name} -

    -

    - {sec.type ?? "Opaque"} -

    -
    - - {formatAge(sec.metadata.creationTimestamp)} - -
    - {keys.length > 0 && ( -
    - {keys.map((k) => ( - - ))} -
    - )} -
  • - ) - })} + {items.map((sec) => ( + + ))}
)}
diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index 8432860..9e4a120 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -1,5 +1,6 @@ +import { useEffect, useRef, useState } from "react" import { Monitor } from "lucide-react" -import { Section } from "@cozystack/ui" +import { Section, Spinner } from "@cozystack/ui" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" interface VncTabProps { @@ -10,6 +11,88 @@ interface VncTabProps { export function VncTab({ ad, instance }: VncTabProps) { const ns = instance.metadata.namespace const appKind = ad.spec?.application.kind + const [error, setError] = useState(null) + const [connecting, setConnecting] = useState(true) + const [connected, setConnected] = useState(false) + const vncContainerRef = useRef(null) + const rfbRef = useRef(null) + + useEffect(() => { + if (appKind !== "VMInstance" || !vncContainerRef.current) return + + let mounted = true + + // Build WebSocket URL through kubectl proxy + const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` + + // Dynamically import RFB + import("@novnc/novnc/lib/rfb").then((module) => { + if (!mounted || !vncContainerRef.current) return + + // The module has nested default: module.default.default is the RFB constructor + const RFB = (module as any).default?.default || module.default || module + + try { + // Initialize noVNC RFB client + const rfb = new RFB(vncContainerRef.current, wsUrl, { + credentials: {}, + }) + + // Set scaling mode + rfb.scaleViewport = true + rfb.resizeSession = false + + // Event handlers + rfb.addEventListener("connect", () => { + if (mounted) { + setConnecting(false) + setConnected(true) + setError(null) + } + }) + + rfb.addEventListener("disconnect", (e: any) => { + if (mounted) { + setConnecting(false) + setConnected(false) + if (!e.detail.clean) { + setError(`Disconnected: ${e.detail.reason || "unknown reason"}`) + } + } + }) + + rfb.addEventListener("securityfailure", (e: any) => { + if (mounted) { + setConnecting(false) + setConnected(false) + setError(`Security failure: ${e.detail.status || "authentication failed"}`) + } + }) + + rfbRef.current = rfb + } catch (err) { + if (mounted) { + setConnecting(false) + setError(`Failed to initialize VNC: ${(err as Error).message}`) + } + } + }).catch((err) => { + if (mounted) { + setConnecting(false) + setError(`Failed to load VNC library: ${err.message}`) + } + }) + + // Cleanup on unmount + return () => { + mounted = false + if (rfbRef.current) { + rfbRef.current.disconnect() + rfbRef.current = null + } + } + }, [appKind, ns, instance.metadata.name]) + if (appKind !== "VMInstance") { return (
@@ -19,12 +102,73 @@ export function VncTab({ ad, instance }: VncTabProps) {
) } - const wsUrl = - `/apis/subresources.kubevirt.io/v1/namespaces/${ns}` + - `/virtualmachineinstances/${instance.metadata.name}/vnc` + + const handleCtrlAltDel = () => { + if (rfbRef.current) { + rfbRef.current.sendCtrlAltDel() + } + } + + const handleDisconnect = () => { + if (rfbRef.current) { + rfbRef.current.disconnect() + } + } + + const handleReconnect = () => { + // Clear container and reinitialize + if (vncContainerRef.current) { + vncContainerRef.current.innerHTML = "" + setConnecting(true) + setError(null) + + const wsUrl = `ws://localhost:8001/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` + + import("@novnc/novnc/lib/rfb").then((module) => { + if (!vncContainerRef.current) return + + // The module has nested default: module.default.default is the RFB constructor + const RFB = (module as any).default?.default || module.default || module + + try { + const rfb = new RFB(vncContainerRef.current, wsUrl, { credentials: {} }) + rfb.scaleViewport = true + rfb.resizeSession = false + + rfb.addEventListener("connect", () => { + setConnecting(false) + setConnected(true) + setError(null) + }) + + rfb.addEventListener("disconnect", (e: any) => { + setConnecting(false) + setConnected(false) + if (!e.detail.clean) { + setError(`Disconnected: ${e.detail.reason || "unknown reason"}`) + } + }) + + rfb.addEventListener("securityfailure", (e: any) => { + setConnecting(false) + setConnected(false) + setError(`Security failure: ${e.detail.status || "authentication failed"}`) + }) + + rfbRef.current = rfb + } catch (err) { + setConnecting(false) + setError(`Failed to reconnect: ${(err as Error).message}`) + } + }).catch((err) => { + setConnecting(false) + setError(`Failed to load VNC library: ${err.message}`) + }) + } + } return ( -
+
@@ -32,18 +176,46 @@ export function VncTab({ ad, instance }: VncTabProps) { } > -

- The KubeVirt VNC endpoint is available at: -

-
-          ws(s)://{``}
-          {wsUrl}
-        
-

- Interactive framebuffer rendering is coming — for now the console must - be opened with a standalone VNC client that speaks the KubeVirt - websocket protocol (e.g. virtctl vnc). -

+
+ {connecting && ( +
+ Connecting to VNC... +
+ )} + {error && ( +
+ Connection Error: {error} +
+ )} +
+
+ + + +
+
) diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index d213257..3e678b2 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react" import { + Archive, Cloud, Database, Globe, @@ -95,6 +96,16 @@ export function useConsoleSidebarSections(): SidebarSection[] { }), })) + const backupsSection: SidebarSection = { + title: "Backups", + items: [ + { label: "Plans", to: "/console/backups/plans", icon: Archive }, + { label: "Backup Jobs", to: "/console/backups/backupjobs", icon: Archive }, + { label: "Backups", to: "/console/backups/backups", icon: Archive }, + { label: "Restore Jobs", to: "/console/backups/restorejobs", icon: Archive }, + ], + } + const administrationSection: SidebarSection = { title: "Administration", items: [ @@ -105,6 +116,6 @@ export function useConsoleSidebarSections(): SidebarSection[] { ], } - return [...categorySections, administrationSection] + return [...categorySections, backupsSection, administrationSection] }, [grouped]) } 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 () => { diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 27a8301..119f2bb 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -36,8 +36,8 @@ export function Sidebar({ sections }: SidebarProps) { } return ( -