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..db26bea651 --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-gateways/src/components/ConfigStatusBar.tsx @@ -0,0 +1,81 @@ +/* + * 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, IconButton, Tooltip, Typography } from '@wso2/oxygen-ui'; +import { RefreshCw } from '@wso2/oxygen-ui-icons-react'; +import { describeStatus } from '../config/status'; +import type { ConfigStatus } from '../types'; + +export type ConfigStatusBarProps = { + status: ConfigStatus; + onRefresh: () => void; + refreshing?: boolean; +}; + +/** + * The configuration's phase as one line of text beside the gateway name — no + * chip: a healthy gateway shows when its configuration last landed, and only a + * phase that is still moving or has gone wrong spends the line on a word. + * `config/status.ts` decides what that line says and why. + * + * The Refresh button stays even though the drawer polls: polling is on a + * 20-second clock and someone watching a change land wants it now. It is also + * the only path that surfaces a read failure, a background poll being silent by + * design. + */ +const ConfigStatusBar: FC = ({ + status, + onRefresh, + refreshing = false, +}) => { + const display = describeStatus(status); + + return ( + + {display ? ( + // An empty title renders no tooltip, so an absent detail needs no branch. + + + {display.text} + + + ) : null} + + {/* 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..9edca86ceb 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,430 @@ * 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, + Tooltip, + 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 { isApplying } from '../config/status'; +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; + +/** + * How often the drawer re-reads the configuration while it is open. + * + * The phase moves on the platform's clock, not the user's: a write comes back + * `applying` and the data plane takes minutes to catch up (10m07s measured on + * 2026-08-31), so nothing the user does will ever be the event that turns the + * line to `healthy`. A read is cheap and idempotent, and it re-seeds `config` + * only — pending edits are untouched — so the cost of polling is one GET and + * the benefit is a status line that is true without anyone pressing anything. + * + * The PUT is never re-sent. Only the phase is being waited on, and it settles on + * its own. + */ +const POLL_INTERVAL_MS = 20_000; + +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); + /** + * The same flag the poll timer reads. `saving` itself cannot be: the interval + * callback closes over the render that created it, so it would see `false` + * forever unless the timer were torn down and rebuilt on every keystroke- + * adjacent state change. + */ + const savingRef = useRef(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 neither Refresh nor the + * poll below can discard them. + * + * `background: true` is the poll's read: it owns no spinner, and a failure + * leaves the form exactly as it is. A poll that lost the network must not + * replace a working form with an error banner every twenty seconds — the + * Refresh button is the path that reports a read failure, because someone + * pressed it and is waiting for an answer. + */ + const load = useCallback( + async (id: string, { background = false }: { background?: boolean } = {}) => { + const mine = ++generation.current; + if (!background) { + setLoading(true); + setLoadError(null); + } + try { + const loaded = await readConfiguration(apiFetch, id); + if (mine !== generation.current) return; + setConfig(loaded); + // A read that worked settles any banner an earlier one left behind. + setLoadError(null); + } catch (error) { + if (mine !== generation.current || background) 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 (!background && mine === generation.current) setLoading(false); + } + }, + [apiFetch] + ); + + useEffect(() => { + if (!open || !gatewayId) return; + void load(gatewayId); + }, [gatewayId, load, open]); + + /** + * Poll while the drawer is open, so `Applying…` becomes a timestamp on its own. + * + * A tick is SKIPPED while a write is in flight. The generation counter settles + * which response wins by which started last, and a read that started before + * the PUT returned can legitimately own the newer generation while carrying + * pre-write values — the one ordering that would leave stale values on screen + * with no pending edits, which reads as "saved" and is not. Once the PUT has + * returned, the binding is written and any read reflects it. + */ + useEffect(() => { + if (!open || !gatewayId) return; + const timer = setInterval(() => { + if (savingRef.current) return; + void load(gatewayId, { background: true }); + }, POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [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 dirtyCount = Object.keys(patch).length; + /** + * The previous change has not reached the data plane yet. Writing again on top + * of it re-renders the release from a document the gateway has not finished + * picking up, so Save waits — and this is the state EVERY write lands in, for + * minutes, which is why the button explains itself rather than just greying out. + */ + const applying = isApplying(config?.status); + const canSave = + dirtyCount > 0 && + Object.keys(clientErrors).length === 0 && + !saving && + !applying; + + 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 environmentName = environments.find((environment) => environment.id === gateway.environmentId)?.name ?? '—'; + /** + * Both user-initiated reads. Refused while a write is in flight: such a read + * carries pre-write values and yet owns the NEWER generation, so the PUT's + * response loses the check below while the drafts clear anyway -- the form + * then shows the pre-write configuration with nothing pending, which reads as + * "saved" and is not. The poll skips its tick for the same reason. + */ + const refresh = () => { + if (savingRef.current || !gatewayId) return; + void load(gatewayId); + }; - 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 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; + savingRef.current = true; + 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 { + savingRef.current = false; + 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 ? ( + + ) : null} + - - {gateway.name} - - - - {rows.map((row, index) => ( - - - {row.label} - - {row.value} - - - {index < rows.length - 1 ? : null} + + + {loading && !config ? ( + + + ) : null} + + {loadError ? ( + + 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. */} + + + {/* Wrapped: a disabled button fires no events, so the tooltip + explaining why it is disabled would never open. */} + + + + + ) : 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 `