From 2a4d1694dcb4758f30d9b6beffd7bb9dd5f819e6 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 19:40:15 +0300 Subject: [PATCH 01/13] fix(console): deterministic Back and Cancel navigation in edit mode In edit mode, Back and Cancel navigate to the resource detail page instead of relying on browser history, which is unstable on direct links. In detail view, Back navigates to the resource list page. Signed-off-by: ohotnikov.ivan --- apps/console/src/routes/ApplicationOrderPage.tsx | 4 ++-- apps/console/src/routes/detail/ApplicationDetailPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/console/src/routes/ApplicationOrderPage.tsx b/apps/console/src/routes/ApplicationOrderPage.tsx index 63f1b97..1377554 100644 --- a/apps/console/src/routes/ApplicationOrderPage.tsx +++ b/apps/console/src/routes/ApplicationOrderPage.tsx @@ -147,7 +147,7 @@ export function ApplicationOrderPage({
@@ -95,9 +96,11 @@ export function ModulesPage() { function ModuleCard({ ad, installed, + tenantName, }: { ad: ApplicationDefinition installed: K8sResource | undefined + tenantName?: string }) { const kind = ad.spec?.application.kind ?? ad.metadata.name const plural = ad.spec?.application.plural ?? ad.metadata.name @@ -105,7 +108,7 @@ function ModuleCard({ const enabled = !!installed const target = enabled ? `/console/${plural}/${singletonName}` - : `/marketplace/${ad.metadata.name}` + : `/console/tenants/${tenantName ?? "root"}/edit` const icon = iconDataUrl(ad) return ( From 84f48eb1591b59512f308fe536538db1b1d5893a Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 19:40:23 +0300 Subject: [PATCH 03/13] feat(console): display authenticated user name in the header Fetches /oauth2/userinfo on startup alongside the app config and passes the email/user field to AppShell. Falls back gracefully when the endpoint is unavailable (dev mode without auth). Signed-off-by: ohotnikov.ivan --- apps/console/src/App.tsx | 9 ++++++--- apps/console/src/lib/config.ts | 11 +++++++++++ apps/console/src/main.tsx | 6 +++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index f341aa6..73effc6 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -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() @@ -32,6 +33,7 @@ function Shell({ config }: ShellProps) { version={import.meta.env.VITE_APP_VERSION} logoSvg={config.logoSvg} logoText={config.logoText} + username={username} > @@ -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 ( - + ) diff --git a/apps/console/src/lib/config.ts b/apps/console/src/lib/config.ts index b30d338..e435a33 100644 --- a/apps/console/src/lib/config.ts +++ b/apps/console/src/lib/config.ts @@ -23,3 +23,14 @@ export async function loadConfig(): Promise { return {} } } + +export async function loadUsername(): Promise { + try { + const resp = await fetch("/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 + } +} diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index ba6838b..d8a6965 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -4,16 +4,16 @@ import { BrowserRouter } from "react-router" import { K8sProvider } from "@cozystack/k8s-client" import "./index.css" import App from "./App.tsx" -import { loadConfig } from "./lib/config.ts" +import { loadConfig, loadUsername } from "./lib/config.ts" -loadConfig().then((config) => { +Promise.all([loadConfig(), loadUsername()]).then(([config, username]) => { if (config.titleText) document.title = config.titleText createRoot(document.getElementById("root")!).render( - + , From c4fce272d0ab51e7d0841b89dc4e6d364cd066dd Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 19:42:17 +0300 Subject: [PATCH 04/13] feat(console): add missing sidebar icons for all application kinds Covers BootBox, ExternalDNS, FoundationDB, Info, OpenSearch, SeaweedFS, and TCPBalancer. TCPBalancer and OpenSearch use Simple Icons (haproxy, opensearch); the rest use Lucide fallbacks. Signed-off-by: ohotnikov.ivan --- apps/console/src/lib/sidebar-icons.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/console/src/lib/sidebar-icons.tsx b/apps/console/src/lib/sidebar-icons.tsx index c8e1478..b57005a 100644 --- a/apps/console/src/lib/sidebar-icons.tsx +++ b/apps/console/src/lib/sidebar-icons.tsx @@ -1,8 +1,12 @@ import type { ComponentType } from "react" import { + Database, + Globe, HardDrive, + Info, Monitor, Network, + Server, Users, type LucideIcon, } from "lucide-react" @@ -33,6 +37,7 @@ const KIND_TO_SIMPLE_ICON: Record = { // NaaS HTTPCache: "nginx", + TCPBalancer: "haproxy", VPN: "wireguard", // Administration @@ -40,6 +45,7 @@ const KIND_TO_SIMPLE_ICON: Record = { Ingress: "nginx", Kubernetes: "kubernetes", Monitoring: "prometheus", + OpenSearch: "opensearch", } /** @@ -47,10 +53,15 @@ const KIND_TO_SIMPLE_ICON: Record = { * Simple Icons. These use the same pack as cozyportal-ui. */ const KIND_TO_LUCIDE_ICON: Record> = { - VMInstance: Monitor, - VMDisk: HardDrive, - VirtualPrivateCloud: Network, + BootBox: Server, + ExternalDNS: Globe, + FoundationDB: Database, + Info: Info, + SeaweedFS: Database, Tenant: Users, + VirtualPrivateCloud: Network, + VMDisk: HardDrive, + VMInstance: Monitor, } export function simpleIconSlug(kind: string): string | undefined { From 9f63d1e72d04088c651168622e1cc4ab42824747 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 19:51:18 +0300 Subject: [PATCH 05/13] fix(console): add 5-second timeout to startup fetch calls loadConfig and loadUsername now abort after 5 seconds so the app always mounts even when the K8s API or oauth2-proxy is unreachable. Previously a hanging fetch would block Promise.all indefinitely, leaving the root div empty. Signed-off-by: ohotnikov.ivan --- apps/console/src/lib/config.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/console/src/lib/config.ts b/apps/console/src/lib/config.ts index e435a33..676faf6 100644 --- a/apps/console/src/lib/config.ts +++ b/apps/console/src/lib/config.ts @@ -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 { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), ms) + return fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer)) +} + export async function loadConfig(): Promise { try { - const resp = await fetch( + const resp = await fetchWithTimeout( `/api/v1/namespaces/${CONFIG_NAMESPACE}/configmaps/${CONFIG_MAP_NAME}`, ) if (!resp.ok) return {} @@ -26,7 +32,7 @@ export async function loadConfig(): Promise { export async function loadUsername(): Promise { try { - const resp = await fetch("/oauth2/userinfo") + 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 From 7a6d6741032691a8238f5c583c848c669088f947 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 20:17:02 +0300 Subject: [PATCH 06/13] fix(sidebar): replace invalid haproxy Simple Icons slug with Lucide Network for TCPBalancer haproxy does not exist in the Simple Icons package (cdn.jsdelivr.net returns 404), causing a broken mask-image in the sidebar. Move TCPBalancer to the Lucide fallback using the Network icon. Signed-off-by: ohotnikov.ivan --- apps/console/src/lib/sidebar-icons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/src/lib/sidebar-icons.tsx b/apps/console/src/lib/sidebar-icons.tsx index b57005a..3d6cc66 100644 --- a/apps/console/src/lib/sidebar-icons.tsx +++ b/apps/console/src/lib/sidebar-icons.tsx @@ -37,7 +37,6 @@ const KIND_TO_SIMPLE_ICON: Record = { // NaaS HTTPCache: "nginx", - TCPBalancer: "haproxy", VPN: "wireguard", // Administration @@ -55,6 +54,7 @@ const KIND_TO_SIMPLE_ICON: Record = { const KIND_TO_LUCIDE_ICON: Record> = { BootBox: Server, ExternalDNS: Globe, + TCPBalancer: Network, FoundationDB: Database, Info: Info, SeaweedFS: Database, From 81c0d5ca1838db7eaaaf72e6946eb4199c015d25 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Mon, 11 May 2026 20:42:10 +0300 Subject: [PATCH 07/13] feat(vmdisk): add dropdowns for source.disk.name and source.image.name SourceField now fetches existing VMDisks from the tenant namespace and available images from cozy-public (PVCs with vm-default-images- prefix), rendering them as native selects instead of plain text inputs. Signed-off-by: ohotnikov.ivan --- apps/console/src/components/SourceField.tsx | 142 ++++++++++++++++---- 1 file changed, 117 insertions(+), 25 deletions(-) diff --git a/apps/console/src/components/SourceField.tsx b/apps/console/src/components/SourceField.tsx index 2079501..837a711 100644 --- a/apps/console/src/components/SourceField.tsx +++ b/apps/console/src/components/SourceField.tsx @@ -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({ + apiGroup: APPS_GROUP, + apiVersion: APPS_VERSION, + plural: "vmdisks", + namespace: tenantNamespace ?? undefined, + }) + return { disks: data?.items ?? [], isLoading } +} + +function useImageOptions() { + const { data, isLoading } = useK8sList({ + 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 @@ -59,31 +103,79 @@ export function SourceField(props: FieldProps) { 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" - /> -
- ))} + {Object.entries(subProps).map(([key, subProp]: [string, any]) => { + const currentValue = (formData?.[option] as Record)?.[key] || "" + const handleChange = (val: string) => { + onChange({ + [option]: { + ...(formData?.[option] as Record), + [key]: val, + }, + }) + } + + const isDiskName = option === "disk" && key === "name" + const isImageName = option === "image" && key === "name" + + return ( +
+ + {subProp.description && ( +

{subProp.description}

+ )} + {isDiskName ? ( + + ) : isImageName ? ( + + ) : ( + 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" + /> + )} +
+ ) + })}
) } From 907a7873f20f371f82878970154861870f6a62e6 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Tue, 12 May 2026 00:22:18 +0300 Subject: [PATCH 08/13] fix(vnc): improve toolbar UX and screen resolution - Add cursor-pointer to toolbar buttons (Tailwind resets cursor to auto) - Enable resizeSession so noVNC sends SetDesktopSize to the VM guest, matching the container resolution instead of upscaling a low-res framebuffer Signed-off-by: ohotnikov.ivan --- apps/console/src/routes/detail/VncTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index 38b53d0..f22f167 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -44,7 +44,7 @@ export function VncTab({ ad, instance }: VncTabProps) { try { const rfb = new RFB(el, wsUrl, { credentials: {} }) rfb.scaleViewport = true - rfb.resizeSession = false + rfb.resizeSession = true // Guard each handler: if rfbRef was replaced by a newer session, ignore // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -274,7 +274,7 @@ function ToolbarButton({ onClick={onClick} disabled={disabled} title={title} - className="flex items-center gap-1 rounded px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-700/60 hover:text-slate-200 disabled:cursor-not-allowed disabled:opacity-30" + className="flex items-center gap-1 rounded px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-700/60 hover:text-slate-200 cursor-pointer disabled:cursor-not-allowed disabled:opacity-30" > {children} {label && {label}} From cc03251a1ae3d6791d8bc3f0dc51e49813bfbdb8 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Tue, 12 May 2026 00:22:24 +0300 Subject: [PATCH 09/13] fix(console): show Terminating badge when resource is being deleted Resources with deletionTimestamp set now show a "Terminating" muted badge instead of the Ready/NotReady condition badge in both the list and detail views. Signed-off-by: ohotnikov.ivan --- apps/console/src/routes/ApplicationListPage.tsx | 4 +++- apps/console/src/routes/detail/ApplicationDetailPage.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/console/src/routes/ApplicationListPage.tsx b/apps/console/src/routes/ApplicationListPage.tsx index 402fd79..a1e35e1 100644 --- a/apps/console/src/routes/ApplicationListPage.tsx +++ b/apps/console/src/routes/ApplicationListPage.tsx @@ -108,7 +108,9 @@ export function ApplicationListPage() { {inst.metadata.name} - {ready ? ( + {inst.metadata.deletionTimestamp ? ( + Terminating + ) : ready ? ( {ready.status === "True" ? "Ready" : (ready.reason ?? "NotReady")} diff --git a/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/apps/console/src/routes/detail/ApplicationDetailPage.tsx index 69c779f..7a398f0 100644 --- a/apps/console/src/routes/detail/ApplicationDetailPage.tsx +++ b/apps/console/src/routes/detail/ApplicationDetailPage.tsx @@ -146,11 +146,13 @@ export function ApplicationDetailPage() {

{name}

- {ready && ( + {instance?.metadata.deletionTimestamp ? ( + Terminating + ) : ready ? ( {ready.status === "True" ? "Ready" : (ready.reason ?? "NotReady")} - )} + ) : null}
From 0eca627a0a3f53e7aa095d1b7af830e311fc6613 Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Tue, 12 May 2026 00:22:28 +0300 Subject: [PATCH 10/13] fix(events): include Helm-managed pods and PVCs in event scope Events tab now also loads pods and PVCs by the Helm release instance label (app.kubernetes.io/instance) so resources like DataVolume that share the HelmRelease name appear in the event stream alongside app-label resources. Signed-off-by: ohotnikov.ivan --- apps/console/src/routes/detail/EventsTab.tsx | 53 ++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/console/src/routes/detail/EventsTab.tsx b/apps/console/src/routes/detail/EventsTab.tsx index 79b9ca2..7409921 100644 --- a/apps/console/src/routes/detail/EventsTab.tsx +++ b/apps/console/src/routes/detail/EventsTab.tsx @@ -55,15 +55,19 @@ interface K8sPVC { } } -export function EventsTab({ ad: _ad, instance }: EventsTabProps) { +export function EventsTab({ ad, instance }: EventsTabProps) { const { tenantNamespace } = useTenantContext() + const helmReleaseName = (ad.spec?.release?.prefix ?? "") + instance.metadata.name + const appLabelSelector = [ `apps.cozystack.io/application.name=${instance.metadata.name}`, `apps.cozystack.io/application.kind=${instance.kind}`, ].join(",") - // Load Pods belonging to this application + const helmLabelSelector = `app.kubernetes.io/instance=${helmReleaseName}` + + // Load Pods belonging to this application (by app label) const { data: podsList } = useK8sList( { apiGroup: "", @@ -77,7 +81,21 @@ export function EventsTab({ ad: _ad, instance }: EventsTabProps) { }, ) - // Load PVCs belonging to this application + // Load Pods belonging to Helm-managed child resources + const { data: helmPodsList } = useK8sList( + { + apiGroup: "", + apiVersion: "v1", + plural: "pods", + namespace: tenantNamespace ?? undefined, + }, + { + enabled: !!tenantNamespace, + labelSelector: helmLabelSelector, + }, + ) + + // Load PVCs belonging to this application (by app label) const { data: pvcList } = useK8sList( { apiGroup: "", @@ -91,6 +109,20 @@ export function EventsTab({ ad: _ad, instance }: EventsTabProps) { }, ) + // Load PVCs belonging to Helm-managed child resources + const { data: helmPvcList } = useK8sList( + { + apiGroup: "", + apiVersion: "v1", + plural: "persistentvolumeclaims", + namespace: tenantNamespace ?? undefined, + }, + { + enabled: !!tenantNamespace, + labelSelector: helmLabelSelector, + }, + ) + const { data: eventsList, isLoading } = useK8sList( { apiGroup: "", @@ -103,14 +135,17 @@ export function EventsTab({ ad: _ad, instance }: EventsTabProps) { }, ) - // Build set of resource names that belong to this application + // Build set of resource names that belong to this application. + // helmReleaseName covers the HelmRelease object itself and any Helm-managed + // child resources that share the same name (e.g. DataVolume for VMDisk). const allEvents = eventsList?.items || [] - const pods = podsList?.items || [] - const pvcs = pvcList?.items || [] + const pods = [...(podsList?.items || []), ...(helmPodsList?.items || [])] + const pvcs = [...(pvcList?.items || []), ...(helmPvcList?.items || [])] const relatedResourceNames = new Set([ - instance.metadata.name, // The instance itself - ...pods.map((pod) => pod.metadata.name), // All pods - ...pvcs.map((pvc) => pvc.metadata.name), // All PVCs + instance.metadata.name, + helmReleaseName, + ...pods.map((pod) => pod.metadata.name), + ...pvcs.map((pvc) => pvc.metadata.name), ]) // Filter events related to this application From 33237134c0933aa639e3817b1d52ae0979f1c55c Mon Sep 17 00:00:00 2001 From: "ohotnikov.ivan" Date: Tue, 12 May 2026 00:22:37 +0300 Subject: [PATCH 11/13] fix(forms): prevent stale RJSF state from overriding user input - VMDiskWidget: remove `disks` from useEffect deps so k8s watch events do not re-trigger auto-select and overwrite a user's disk selection; also fixes first-click-does-nothing on the disks array AddButton - SchemaForm: emit getDefaultFormState on schema load so the parent spec is never empty when the user submits without touching any field (#5) - rjsf-templates: add cursor-pointer to AddButton, strip non-button RJSF props (uiSchema, registry, iconType) from IconButton to avoid React warnings Signed-off-by: ohotnikov.ivan --- apps/console/src/components/SchemaForm.tsx | 16 +++++++++++++++- apps/console/src/components/VMDiskWidget.tsx | 15 +++++++++++---- apps/console/src/components/rjsf-templates.tsx | 5 +++-- apps/console/src/routes/ApplicationOrderPage.tsx | 1 - 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index 5f728c2..b0e7d87 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -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" @@ -165,6 +166,19 @@ export function SchemaForm({ } }, [openAPISchema]) + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const initialFormDataRef = useRef(formData) + + // Emit defaults to parent on schema load so spec is never empty on first submit. + // Uses initialFormDataRef so edit-mode existing values are preserved as base. + useEffect(() => { + if (!schema || Object.keys(schema).length === 0) return + const defaults = getDefaultFormState(validator, schema, initialFormDataRef.current ?? {}, schema) + onChangeRef.current(defaults) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schema]) + const uiSchema = useMemo(() => { const baseUiSchema: UiSchema = { "ui:submitButtonOptions": { norender: true }, diff --git a/apps/console/src/components/VMDiskWidget.tsx b/apps/console/src/components/VMDiskWidget.tsx index 4cb00ff..2eb4725 100644 --- a/apps/console/src/components/VMDiskWidget.tsx +++ b/apps/console/src/components/VMDiskWidget.tsx @@ -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" @@ -30,12 +30,19 @@ 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 first disk if required and no value set. + // `disks` is intentionally omitted from deps: a new array reference from k8s watch + // would otherwise re-run this effect and overwrite a user's selection. + // `onChange` is also excluded — it changes reference every render. useEffect(() => { if (required && !value && disks.length > 0 && !isLoading) { - onChange(disks[0].metadata.name) + onChangeRef.current(disks[0].metadata.name) } - }, [required, value, disks, isLoading, onChange]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [required, value, isLoading]) return (
{icon ? : null} diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index f22f167..43386af 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -130,6 +130,7 @@ export function VncTab({ ad, instance }: VncTabProps) { try { rfbRef.current.disconnect() } catch {} rfbRef.current = null } + setDesktopSize(null) setConnectionKey((k) => k + 1) }