Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import type { AppConfig } from "./lib/config.ts"

interface ShellProps {
config: AppConfig
username?: string
}

function Shell({ config }: ShellProps) {
function Shell({ config, username }: ShellProps) {
const { pathname } = useLocation()
const inMarketplace = pathname.startsWith("/marketplace")
const marketplaceSections = useMarketplaceSidebarSections()
Expand All @@ -32,6 +33,7 @@ function Shell({ config }: ShellProps) {
version={import.meta.env.VITE_APP_VERSION}
logoSvg={config.logoSvg}
logoText={config.logoText}
username={username}
>
<CommandPalette />
<Routes>
Expand All @@ -45,13 +47,14 @@ function Shell({ config }: ShellProps) {

export interface AppProps {
config?: AppConfig
username?: string
}

export default function App({ config = {} }: AppProps) {
export default function App({ config = {}, username }: AppProps) {
return (
<TenantProvider>
<CommandPaletteProvider>
<Shell config={config} />
<Shell config={config} username={username} />
</CommandPaletteProvider>
</TenantProvider>
)
Expand Down
34 changes: 27 additions & 7 deletions apps/console/src/components/ResourceQuotasField.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react"
import { useState, useEffect, useRef } from "react"
import type { FieldProps } from "@rjsf/utils"
import { X, Plus } from "lucide-react"

Expand Down Expand Up @@ -62,12 +62,31 @@ function KnownRowEditor({ row, value, onChange, readonly }: KnownRowEditorProps)
return parseSize(value, row.units).unit
})

// expectedValueRef tracks values we emitted ourselves so we can distinguish
// an external reset (e.g. form data cleared from outside) from our own onChange.
const expectedValueRef = useRef(value)

useEffect(() => {
if (value === expectedValueRef.current) return
expectedValueRef.current = value
const hasValue = value !== undefined && value !== ""
setChecked(hasValue)
if (hasValue && value) {
setLocalNum(row.units ? parseSize(value, row.units).num : value)
if (row.units) setLocalUnit(parseSize(value, row.units).unit)
} else {
setLocalNum("")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])

const enabled = checked

const handleToggle = () => {
if (enabled) {
setChecked(false)
setLocalNum("")
expectedValueRef.current = undefined
onChange(undefined)
} else {
setChecked(true)
Expand All @@ -80,20 +99,21 @@ function KnownRowEditor({ row, value, onChange, readonly }: KnownRowEditorProps)
const trimmed = num.trim()
if (!trimmed) {
// Clear from parent data but keep checkbox checked locally
expectedValueRef.current = undefined
onChange(undefined)
return
}
if (row.units) {
onChange(formatSize(trimmed, localUnit))
} else {
onChange(trimmed)
}
const next = row.units ? formatSize(trimmed, localUnit) : trimmed
expectedValueRef.current = next
onChange(next)
}

const handleUnitChange = (unit: string) => {
setLocalUnit(unit)
if (localNum.trim()) {
onChange(formatSize(localNum, unit))
const next = formatSize(localNum, unit)
expectedValueRef.current = next
onChange(next)
}
}

Expand Down
21 changes: 20 additions & 1 deletion apps/console/src/components/SchemaForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from "react"
import { useMemo, useEffect, useRef } from "react"
import Form from "@rjsf/core"
import validator from "@rjsf/validator-ajv8"
import { getDefaultFormState } 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"
Expand Down Expand Up @@ -165,6 +166,24 @@ export function SchemaForm({
}
}, [openAPISchema])

const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const initialFormDataRef = useRef(formData)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The initialFormDataRef is initialized only once during the component's mount and is never updated. If formData is provided asynchronously by the parent (for example, after an API fetch), this ref will remain empty. When the schema is loaded and the useEffect runs, getDefaultFormState will use the stale empty object as a base, and the subsequent onChange(defaults) call will overwrite the parent's newly loaded data with just the schema defaults.

To fix this, ensure the ref is updated on every render so that the effect always uses the most recent formData when calculating the default state.

Suggested change
const initialFormDataRef = useRef(formData)
const initialFormDataRef = useRef(formData)
initialFormDataRef.current = formData

const emittedSchemaRef = useRef<RJSFSchema | null>(null)

// Emit defaults to parent once per schema so spec is never empty on first submit.
// Uses initialFormDataRef so edit-mode existing values are preserved as base.
// emittedSchemaRef prevents re-running on unrelated re-renders and avoids
// overwriting user data if the schema object changes identity unexpectedly.
useEffect(() => {
if (!schema || Object.keys(schema).length === 0) return
if (emittedSchemaRef.current === schema) return
emittedSchemaRef.current = schema
const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema)
onChangeRef.current(defaults)
Comment on lines +171 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the latest formData as the defaults base.

Line 171 captures formData only once, but Line 182 computes defaults later. If formData changes before emission (or schema changes in-place), defaults can be derived from stale data and overwrite expected values.

💡 Suggested fix
-  const initialFormDataRef = useRef(formData)
+  const latestFormDataRef = useRef(formData)
+  latestFormDataRef.current = formData
   const emittedSchemaRef = useRef<RJSFSchema | null>(null)
@@
-    // Uses initialFormDataRef so edit-mode existing values are preserved as base.
+    // Uses latest formData so async-loaded edit values are preserved as base.
@@
-    const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema)
+    const defaults = getDefaultFormState(validator, schema, latestFormDataRef.current ?? {}, schema)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const initialFormDataRef = useRef(formData)
const emittedSchemaRef = useRef<RJSFSchema | null>(null)
// Emit defaults to parent once per schema so spec is never empty on first submit.
// Uses initialFormDataRef so edit-mode existing values are preserved as base.
// emittedSchemaRef prevents re-running on unrelated re-renders and avoids
// overwriting user data if the schema object changes identity unexpectedly.
useEffect(() => {
if (!schema || Object.keys(schema).length === 0) return
if (emittedSchemaRef.current === schema) return
emittedSchemaRef.current = schema
const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema)
onChangeRef.current(defaults)
const latestFormDataRef = useRef(formData)
latestFormDataRef.current = formData
const emittedSchemaRef = useRef<RJSFSchema | null>(null)
// Emit defaults to parent once per schema so spec is never empty on first submit.
// Uses latest formData so async-loaded edit values are preserved as base.
// emittedSchemaRef prevents re-running on unrelated re-renders and avoids
// overwriting user data if the schema object changes identity unexpectedly.
useEffect(() => {
if (!schema || Object.keys(schema).length === 0) return
if (emittedSchemaRef.current === schema) return
emittedSchemaRef.current = schema
const defaults = getDefaultFormState(validator, schema, latestFormDataRef.current ?? {}, schema)
onChangeRef.current(defaults)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/components/SchemaForm.tsx` around lines 171 - 183, The
effect currently uses a one-time captured initialFormDataRef (set from formData
at mount) when calling getDefaultFormState, which can produce stale defaults;
update the effect to use the latest formData as the base before computing
defaults (either assign initialFormDataRef.current = formData at the start of
the useEffect or pass the current formData directly into getDefaultFormState) so
emitted defaults reflect the latest values; keep the rest of the
emittedSchemaRef and onChangeRef usage the same to avoid extra re-renders.

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schema])

const uiSchema = useMemo<UiSchema>(() => {
const baseUiSchema: UiSchema = {
"ui:submitButtonOptions": { norender: true },
Expand Down
142 changes: 117 additions & 25 deletions apps/console/src/components/SourceField.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,54 @@
import { useState } from "react"
import type { FieldProps } from "@rjsf/utils"
import { useK8sList } from "@cozystack/k8s-client"
import { APPS_GROUP, APPS_VERSION } from "@cozystack/types"
import { useTenantContext } from "../lib/tenant-context.tsx"

const IMAGE_PVC_PREFIX = "vm-default-images-"

interface VMDisk {
apiVersion: string
kind: string
metadata: { name: string; namespace: string }
spec: { storage: string }
}

interface PVC {
apiVersion: string
kind: string
metadata: { name: string; namespace: string }
}

function useVMDiskOptions(tenantNamespace: string | null | undefined) {
const { data, isLoading } = useK8sList<VMDisk>({
apiGroup: APPS_GROUP,
apiVersion: APPS_VERSION,
plural: "vmdisks",
namespace: tenantNamespace ?? undefined,
})
return { disks: data?.items ?? [], isLoading }
}

function useImageOptions() {
const { data, isLoading } = useK8sList<PVC>({
apiGroup: "",
apiVersion: "v1",
plural: "persistentvolumeclaims",
namespace: "cozy-public",
})
const images = (data?.items ?? [])
.filter((pvc) => pvc.metadata.name.startsWith(IMAGE_PVC_PREFIX))
.map((pvc) => pvc.metadata.name.slice(IMAGE_PVC_PREFIX.length))
return { images, isLoading }
}

export function SourceField(props: FieldProps) {
const { schema, formData, onChange, name, required, idSchema } = props
const properties = (schema as any).properties || {}
const options = Object.keys(properties)
const { tenantNamespace } = useTenantContext()
const { disks, isLoading: disksLoading } = useVMDiskOptions(tenantNamespace)
const { images, isLoading: imagesLoading } = useImageOptions()

// Determine which option is currently selected
const currentOption = formData
Expand Down Expand Up @@ -59,31 +103,79 @@ export function SourceField(props: FieldProps) {
const subProps = prop.properties || {}
return (
<div className="ml-6 mt-2 space-y-2">
{Object.entries(subProps).map(([key, subProp]: [string, any]) => (
<div key={key} className="flex flex-col gap-1">
<label className="text-sm font-medium text-slate-700">
{subProp.title || key}
{prop.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
</label>
{subProp.description && (
<p className="text-xs text-slate-500">{subProp.description}</p>
)}
<input
type="text"
value={(formData?.[option] as Record<string, string>)?.[key] || ""}
onChange={(e) => {
onChange({
[option]: {
...(formData?.[option] as Record<string, unknown>),
[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"
/>
</div>
))}
{Object.entries(subProps).map(([key, subProp]: [string, any]) => {
const currentValue = (formData?.[option] as Record<string, string>)?.[key] || ""
const handleChange = (val: string) => {
onChange({
[option]: {
...(formData?.[option] as Record<string, unknown>),
[key]: val,
},
})
}

const isDiskName = option === "disk" && key === "name"
const isImageName = option === "image" && key === "name"

return (
<div key={key} className="flex flex-col gap-1">
<label className="text-sm font-medium text-slate-700">
{subProp.title || key}
{prop.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
</label>
{subProp.description && (
<p className="text-xs text-slate-500">{subProp.description}</p>
)}
{isDiskName ? (
<select
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
disabled={disksLoading}
className="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 disabled:opacity-50"
>
<option value="">-- Select disk --</option>
{disksLoading ? (
<option value="" disabled>Loading...</option>
) : disks.length === 0 ? (
<option value="" disabled>No disks available</option>
) : (
disks.map((disk) => (
<option key={disk.metadata.name} value={disk.metadata.name}>
{disk.metadata.name} ({disk.spec.storage})
</option>
))
)}
</select>
) : isImageName ? (
<select
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
disabled={imagesLoading}
className="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 disabled:opacity-50"
>
<option value="">-- Select image --</option>
{imagesLoading ? (
<option value="" disabled>Loading...</option>
) : images.length === 0 ? (
<option value="" disabled>No images available</option>
) : (
images.map((img) => (
<option key={img} value={img}>{img}</option>
))
)}
</select>
) : (
<input
type="text"
value={currentValue}
onChange={(e) => handleChange(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"
/>
)}
</div>
)
})}
</div>
)
}
Expand Down
16 changes: 12 additions & 4 deletions apps/console/src/components/VMDiskWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import type { WidgetProps } from "@rjsf/utils"
import { useK8sList } from "@cozystack/k8s-client"
import { APPS_GROUP, APPS_VERSION } from "@cozystack/types"
Expand Down Expand Up @@ -30,12 +30,20 @@ export function VMDiskWidget(props: WidgetProps) {

const disks = diskList?.items || []

// Auto-select first disk if required and no value set
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange

// Auto-select the first disk once when disks become available and no value is set.
// A ref prevents re-triggering on every k8s watch event (new array reference)
// while still firing correctly when the list loads after mount.
const autoSelectedRef = useRef(false)
useEffect(() => {
if (autoSelectedRef.current) return
if (required && !value && disks.length > 0 && !isLoading) {
onChange(disks[0].metadata.name)
autoSelectedRef.current = true
onChangeRef.current(disks[0].metadata.name)
}
}, [required, value, disks, isLoading, onChange])
}, [required, value, disks, isLoading])

return (
<select
Expand Down
5 changes: 3 additions & 2 deletions apps/console/src/components/rjsf-templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ function IconButton<
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: IconButtonProps<T, S, F>) {
const { icon, className, ...btnProps } = props
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { icon, className, uiSchema, registry, iconType, ...btnProps } = props
return (
<button
type="button"
Expand Down Expand Up @@ -70,7 +71,7 @@ export const customTemplates = {
<IconButton
{...props}
icon="+ add"
className="mt-0.5 flex items-center gap-1 px-2 py-1 text-xs font-medium text-slate-500 hover:text-blue-600 rounded-md border border-dashed border-slate-300 hover:border-blue-400 hover:bg-blue-50/60 bg-white transition-all duration-150"
className="mt-0.5 flex items-center gap-1 px-2 py-1 text-xs font-medium text-slate-500 hover:text-blue-600 rounded-md border border-dashed border-slate-300 hover:border-blue-400 hover:bg-blue-50/60 bg-white transition-all duration-150 cursor-pointer"
/>
),
RemoveButton: (props: IconButtonProps) => (
Expand Down
19 changes: 18 additions & 1 deletion apps/console/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ export interface AppConfig {
const CONFIG_NAMESPACE = "cozy-dashboard"
const CONFIG_MAP_NAME = "cozy-dashboard-console-config"

function fetchWithTimeout(url: string, ms = 5000): Promise<Response> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), ms)
return fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer))
}

export async function loadConfig(): Promise<AppConfig> {
try {
const resp = await fetch(
const resp = await fetchWithTimeout(
`/api/v1/namespaces/${CONFIG_NAMESPACE}/configmaps/${CONFIG_MAP_NAME}`,
)
if (!resp.ok) return {}
Expand All @@ -23,3 +29,14 @@ export async function loadConfig(): Promise<AppConfig> {
return {}
}
}

export async function loadUsername(): Promise<string | undefined> {
try {
const resp = await fetchWithTimeout("/oauth2/userinfo")
if (!resp.ok) return undefined
const info = await resp.json() as { user?: string; email?: string }
return info.email ?? info.user ?? undefined
} catch {
return undefined
}
}
Loading