From f3377816f25a89dce00fc424f0ca39729e96b247 Mon Sep 17 00:00:00 2001 From: Kavinda Rajapakse Date: Fri, 4 Sep 2026 11:09:10 +0530 Subject: [PATCH 1/4] Add the gateway configuration drawer to the cloud gateways plugin Brings the configuration popup from #3357 onto the wiring in #3362, and only the popup: the listing, page-override and routing work in that PR is superseded by #3362's, so none of it is carried over. The drawer is rendered ENTIRELY FROM THE RESPONSE. The platform reads its editable-field allowlist at request time, so `editable[]` is the form definition and `constraints[]` the cross-field rules -- there is deliberately no client-side copy of either, and a setting the deployment adds or withdraws appears or disappears without a plugin release. Writes are a sparse patch of only the paths the user touched; the response is the whole configuration after the write, so it is both the confirmation and the new baseline (which is what makes a canonicalized quantity stop looking edited, and why there is no second GET). Differences from #3357, all following from #3362's Port and data flow: - `apiFetch` is required on the Port and resolves `T | undefined` for an empty body. Both configuration endpoints always answer with the whole document, so `config/api.ts` treats an empty one as a broken response rather than letting `undefined` reach a form that cannot render it. - No `isManaged`. #3362 lists `/managed-gateways`, so every row already has a managed binding and the Configure action needs no gate -- #3357 needed one only because it listed `/gateways` and joined. - `GatewaysList` takes `port` for the drawer, alongside the `environments` it already had. The old drawer was the only thing reading `environments` when this was written against #3362; #3378 has since given the list its own Environment column, which still needs it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/GatewaysFeature.tsx | 1 + .../src/GatewaysList.tsx | 8 +- .../src/components/ConfigStatusBar.tsx | 87 ++++ .../src/components/GatewaySettingsDrawer.tsx | 362 +++++++++++++--- .../src/components/SettingField.tsx | 227 ++++++++++ .../src/components/TomlField.tsx | 183 ++++++++ .../apip-cloud-ui-gateways/src/config/api.ts | 64 +++ .../src/config/duration.test.ts | 84 ++++ .../src/config/duration.ts | 76 ++++ .../src/config/quantity.test.ts | 88 ++++ .../src/config/quantity.ts | 76 ++++ .../src/config/toml.test.ts | 92 ++++ .../apip-cloud-ui-gateways/src/config/toml.ts | 74 ++++ .../src/config/validate.test.ts | 408 ++++++++++++++++++ .../src/config/validate.ts | 315 ++++++++++++++ .../apip-cloud-ui-gateways/src/types.ts | 67 +++ .../tsconfig.console.json | 22 + .../apip-cloud-ui-gateways/tsconfig.json | 5 +- 18 files changed, 2188 insertions(+), 51 deletions(-) create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/components/ConfigStatusBar.tsx create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/components/SettingField.tsx create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/components/TomlField.tsx create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/api.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/duration.test.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/duration.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/quantity.test.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/quantity.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/toml.test.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/toml.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/validate.test.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/src/config/validate.ts create mode 100644 portals/cloud-plugins/apip-cloud-ui-gateways/tsconfig.console.json diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx index ebdbd9147d..5651041274 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysFeature.tsx @@ -193,6 +193,7 @@ const GatewaysFeature: FC = ({ port, gatewayTypes }) => { setView('create')} onEditClick={(gatewayId) => { setEditingGatewayId(gatewayId); diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx index a696e13efe..38dd0b0e0b 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/GatewaysList.tsx @@ -47,11 +47,14 @@ import { Edit, Plus, Search, Settings, Trash2 } from '@wso2/oxygen-ui-icons-reac import GatewaySettingsDrawer from './components/GatewaySettingsDrawer'; import { gatewayTypeLabel } from './utils/gateway'; import NoGatewaysImage from './assets/images/NoGW.svg'; +import type { AIWorkspaceHostPort } from './hostPort'; import type { Environment, Gateway } from './types'; export type GatewaysListProps = { gateways: Gateway[]; environments: Environment[]; + /** Passed through to the configuration drawer, which calls platform-api itself. */ + port: AIWorkspaceHostPort; onAddClick: () => void; onEditClick: (gatewayId: string) => void; onDelete: (gatewayId: string, name: string) => void; @@ -65,6 +68,7 @@ function truncateText(text: string, maxLength: number): string { const GatewaysList: FC = ({ gateways, environments, + port, onAddClick, onEditClick, onDelete, @@ -270,11 +274,13 @@ const GatewaysList: FC = ({ + {/* Keyed by gateway so the form's draft state belongs to one gateway and cannot outlive it. */} setSettingsGateway(null)} gateway={settingsGateway} - environments={environments} + port={port} /> ); diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/ConfigStatusBar.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/ConfigStatusBar.tsx new file mode 100644 index 0000000000..4c1e716c1d --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/ConfigStatusBar.tsx @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { FC } from 'react'; +import { Box, Chip, IconButton, Tooltip } from '@wso2/oxygen-ui'; +import { RefreshCw } from '@wso2/oxygen-ui-icons-react'; +import type { ConfigPhase, ConfigStatus } from '../types'; + +export type ConfigStatusBarProps = { + status: ConfigStatus; + onRefresh: () => void; + refreshing?: boolean; +}; + +/** + * The phase of the last configuration change, as one chip beside the gateway + * name. + * + * `applying` is the expected state immediately after ANY write and can persist + * for minutes -- it is not a failure. The `message` that often accompanies it + * is deliberately NOT shown: it is prose of unbounded length, it pushed the + * form down the drawer, and the phase word is the part a reader acts on. It + * stays in the response for anyone reading the endpoint directly. + */ +type ChipColor = 'default' | 'info' | 'error' | 'success'; + +const PHASES: Record = { + applying: { color: 'info', label: 'Applying' }, + failed: { color: 'error', label: 'Failed' }, + healthy: { color: 'success', label: 'Healthy' }, +}; + +const ConfigStatusBar: FC = ({ + status, + onRefresh, + refreshing = false, +}) => { + // An unrecognised phase is a newer platform than this build; say the word it + // sent rather than mislabelling it as healthy. + const phase = PHASES[status.phase] ?? { + color: 'default' as ChipColor, + label: status.phase, + }; + + return ( + + + + {/* Wrapped: a disabled button fires no events, so the tooltip on it + would never open while a refresh is in flight. */} + + + + + + + + ); +}; + +export default ConfigStatusBar; diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/GatewaySettingsDrawer.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/GatewaySettingsDrawer.tsx index 5a9dcf8251..6cf982a473 100644 --- a/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/GatewaySettingsDrawer.tsx +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/GatewaySettingsDrawer.tsx @@ -16,73 +16,337 @@ * under the License. */ -import type { FC } from 'react'; -import { Box, Divider, Drawer, IconButton, Typography } from '@wso2/oxygen-ui'; +import { useCallback, useEffect, useMemo, useRef, useState, type FC } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Drawer, + IconButton, + Typography, +} from '@wso2/oxygen-ui'; import { X } from '@wso2/oxygen-ui-icons-react'; -import { relativeTime } from '../utils/time'; -import { gatewayTypeLabel } from '../utils/gateway'; -import type { Gateway, Environment } from '../types'; +import { readConfiguration, writeConfiguration } from '../config/api'; +import { + fieldForServerMessage, + validateForm, + withoutPathPrefix, + type FieldErrors, +} from '../config/validate'; +import type { AIWorkspaceHostPort } from '../hostPort'; +import type { ConfigValues, Gateway, GatewayConfiguration } from '../types'; +import ConfigStatusBar from './ConfigStatusBar'; +import SettingField from './SettingField'; +import TomlField from './TomlField'; export type GatewaySettingsDrawerProps = { open: boolean; onClose: () => void; gateway: Gateway | null; - environments: Environment[]; + port: AIWorkspaceHostPort; }; -const GatewaySettingsDrawer: FC = ({ open, onClose, gateway, environments }) => { - if (!gateway) return null; +/** + * The gateway's configuration form. + * + * Rendered entirely FROM THE RESPONSE: the platform reads its allowlist at + * request time, so `editable[]` is the field list and `constraints[]` the + * cross-field rules, and there is deliberately no client-side copy of either. A + * setting the deployment adds or withdraws appears or disappears here without a + * plugin release. + * + * The caller mounts this with `key={gateway.id}`, so its state belongs to one + * gateway and cannot outlive it. + */ + +const DRAWER_WIDTH = 520; + +const GatewaySettingsDrawer: FC = ({ + open, + onClose, + gateway, + port, +}) => { + const { apiFetch, notify } = port; + const gatewayId = gateway?.id; + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + /** Only the paths the user has touched. The request body is a sparse patch of exactly these. */ + const [drafts, setDrafts] = useState({}); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [serverErrors, setServerErrors] = useState({}); + + /** + * Bumped by every read AND every write, so a response can tell whether it is + * still the newest thing in flight. + * + * A refresh started before a save can land after it. Both call `setConfig`, + * so without this the older GET would replace the configuration the PUT just + * confirmed -- leaving stale values on screen with no pending edits, which + * reads as "saved" and is not. + */ + const generation = useRef(0); + + /** Re-seeds `config` only. Pending edits survive, so Refresh cannot discard them. */ + const load = useCallback( + async (id: string) => { + const mine = ++generation.current; + setLoading(true); + setLoadError(null); + try { + const loaded = await readConfiguration(apiFetch, id); + if (mine !== generation.current) return; + setConfig(loaded); + } catch (error) { + if (mine !== generation.current) return; + setLoadError( + error instanceof Error + ? error.message + : 'The configuration could not be loaded.' + ); + } finally { + // Only the newest request owns the spinner; a superseded one must not + // turn it off while its replacement is still running. + if (mine === generation.current) setLoading(false); + } + }, + [apiFetch] + ); + + useEffect(() => { + if (!open || !gatewayId) return; + void load(gatewayId); + }, [gatewayId, load, open]); + + /** Edits that actually differ from what is stored — editing a field back to its original un-dirties it. */ + const patch = useMemo(() => { + if (!config) return {}; + return Object.fromEntries( + Object.entries(drafts).filter(([path, value]) => { + // Typing into a field the platform carries NO value for and then + // clearing it again is not an edit. The stored value reads back + // `undefined` while an emptied input reads `''`, so a plain `!==` + // called that a change and left the form permanently dirty — with a + // validation error under a field the user had just put back the way + // they found it. There is no "unset" operation on the endpoint: an + // empty input on an unset field means the chart default still stands, + // which is exactly the state before the typing. + if (!(path in config.values)) return value !== '' && value !== undefined; + return value !== config.values[path]; + }) + ); + }, [config, drafts]); + + const clientErrors = useMemo( + () => (config ? validateForm(config, patch) : {}), + [config, patch] + ); + const errors = { ...serverErrors, ...clientErrors }; - const environmentName = environments.find((environment) => environment.id === gateway.environmentId)?.name ?? '—'; + const dirtyCount = Object.keys(patch).length; + const canSave = + dirtyCount > 0 && Object.keys(clientErrors).length === 0 && !saving; - const rows = [ - { label: 'Type', value: gatewayTypeLabel(gateway.type) }, - { label: 'Environment', value: environmentName }, - { label: 'URL', value: gateway.url || '—' }, - { label: 'Status', value: gateway.status === 'active' ? 'Active' : 'Inactive' }, - { label: 'Version', value: gateway.version || '—' }, - { label: 'Critical', value: gateway.isCritical ? 'Yes' : 'No' }, - { label: 'Created', value: relativeTime(gateway.createdAt) }, - { label: 'Last Updated', value: relativeTime(gateway.updatedAt) }, - ]; + const setDraft = (path: string, value: unknown) => { + setDrafts((current) => ({ ...current, [path]: value })); + // A server message is about the value that was sent, so it stops applying + // the moment the field changes. + setServerErrors(({ [path]: _sent, ...rest }) => rest); + }; + + const save = async () => { + if (!config || !gatewayId) return; + // Invalidates any read still in flight: what the write returns is newer + // than anything a GET started before it can report. + const mine = ++generation.current; + setSaving(true); + setSaveError(null); + setServerErrors({}); + try { + // The response is the WHOLE configuration after the write, in the GET's + // shape — so it is both the confirmation and the new baseline. Re-seeding + // from it is what makes a canonicalized quantity ("1000m" -> "1") stop + // looking edited, and why there is no second GET here. + const written = await writeConfiguration(apiFetch, gatewayId, patch); + // The write happened, so it is confirmed and the edits are no longer + // pending whatever else is in flight. Only the BASELINE is conditional: + // a refresh started after this write owns the newer generation and its + // response is about to arrive, so let it install the values rather than + // fighting over them. + if (mine === generation.current) setConfig(written); + setDrafts({}); + notify('Configuration saved.', 'success'); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'The configuration could not be saved.'; + // A field-level message begins with the setting path it is about; + // anything else is form-level. Either way the platform's own sentence is + // user-presentable prose, so it is surfaced verbatim. + const path = fieldForServerMessage( + message, + config.editable.map((field) => field.path) + ); + // Under a field the path is dropped — the label already says which + // setting this is. The banner keeps the full sentence, having no label + // to lean on. + if (path) setServerErrors({ [path]: withoutPathPrefix(message, path) }); + else setSaveError(message); + } finally { + setSaving(false); + } + }; + + if (!gateway) return null; + + // `string` carries its own warnings and character budget, so it is + // partitioned out of the flat list and rendered by `TomlField`. + const listed = config?.editable.filter((field) => field.type !== 'string') ?? []; + const freeText = config?.editable.filter((field) => field.type === 'string') ?? []; + const currentValue = (path: string): unknown => + path in drafts ? drafts[path] : config?.values[path]; return ( - - - Gateway Configuration - - - + + + + + Gateway Configuration + + + + + + + + {gateway.name} + + {config ? ( + gatewayId && void load(gatewayId)} + /> + ) : null} + - - {gateway.name} - - - - {rows.map((row, index) => ( - - - {row.label} - - {row.value} - - - {index < rows.length - 1 ? : null} + + + {loading && !config ? ( + + + ) : null} + + {loadError ? ( + gatewayId && void load(gatewayId)} + > + Retry + + } + > + {loadError} + + ) : null} + + {saveError ? ( + + {saveError} + + ) : null} + + {listed.map((field) => ( + setDraft(field.path, value)} + /> + ))} + + {freeText.map((field) => ( + setDraft(field.path, value)} + /> ))} - {gateway.description ? ( - <> - - - Description - - - {gateway.description} - - + {config ? ( + + {/* Restores the last loaded values — NOT platform defaults, which is not an operation the endpoint has. */} + + + ) : null} diff --git a/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/SettingField.tsx b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/SettingField.tsx new file mode 100644 index 0000000000..500c562f66 --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/SettingField.tsx @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { FC } from 'react'; +import { + Box, + FormHelperText, + IconButton, + MenuItem, + Select, + Switch, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { CircleHelp } from '@wso2/oxygen-ui-icons-react'; +import type { EditableField } from '../types'; + +export type SettingFieldProps = { + field: EditableField; + /** The value to show: the pending edit if there is one, else the stored value. */ + value: unknown; + error?: string; + readOnly?: boolean; + onChange: (value: unknown) => void; +}; + +/** + * One `editable` entry rendered as one row. + * + * Everything shown comes from the response — `label` and `description` are the + * platform's own user-facing copy and are used verbatim, not shortened. In + * particular the two replica labels ("Gateway controller replicas" vs "Gateway + * runtime replicas") name DIFFERENT pods and must never both become "Replicas". + * + * Only two things ever sit beside a control, split by WHEN they are needed, + * because sixteen fields of prose is three screens of scrolling: + * + * error needed always -> inline, and never behind a hover + * description needed once, before -> behind the `?` + * + * Bounds are NOT shown. They were a permanent second line under every field + * for something that only matters while typing, and the message that arrives + * when a value is actually out of range states them anyway. + * + * `string` is not handled here: its one field today is a multi-line TOML block + * whose copy is an operational warning rather than a description, so it keeps + * persistent text and its own component (`TomlField`). + */ + +/** What to put in a text input for a value that may not be a string yet. */ +const inputText = (value: unknown): string => + value === undefined || value === null ? '' : String(value); + +/* + * Every control carries `aria-label={label}`. The visible label is a + * `Typography` in the row beside it, not an `