Bug issue:
I wasn't able to create more port forwards after the first one, nor change its custom domain settings. It kept saying "saving..." but nothing happened.
I debugged with AI which gave the following writeup (I have confirmed this is the case by using the API directly which worked):
Summary
The VM port and proxy editors submit read-model port objects as update-model objects. This breaks updates when any existing port has an HTTP proxy with a custom domain.
The relevant API types are structurally different:
// Read model
interface HttpProxyRead {
name: string;
url?: string;
customDomain?: {
domain: string;
url: string;
status: string;
secret: string;
};
}
// Update model
interface HttpProxyUpdate {
name?: string;
customDomain?: string;
}
A TypeScript assertion is being used where an actual read-to-update conversion is required. Assertions do not transform the runtime object, so an untouched custom domain is sent as an object even though the API expects a string.
There is a second issue: updateVM() does not check response.ok. API validation errors are JSON objects, so they are treated as successful responses, passed to queueJob(), and presented as "saving..." instead of showing an error.
Steps to reproduce
- Create a VM port-forwarding rule.
- Add an HTTP proxy with an active custom domain to that rule.
- Try either of the following:
- add another port-forwarding rule in Port forwarding; or
- add/change custom-domain settings for another proxy.
- Observe that the UI says it is saving, but the change is absent after refreshing.
Actual behavior
- The frontend submits an invalid update payload containing a read-side
customDomain object.
- The API rejects the request.
- The frontend treats the JSON error response as a successful job response.
- The UI displays "saving...", but nothing changes.
Expected behavior
- Every port should be explicitly converted to
PortUpdate before submission.
- Existing proxies and custom domains should remain unchanged unless explicitly edited.
- Non-2xx responses should display the API error and should not be passed to the job queue.
PortManager cause
PortManager initializes its state directly from vm.ports, then adds the new port to that read-model array:
https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/PortManager.tsx#L60-L108
The incompatible array is hidden with:
ports: newPorts as PortUpdate[]
If an existing port has a custom domain, its CustomDomainRead object is serialized unchanged.
ProxyManager cause
ProxyManager also starts with the full read-model array:
https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/ProxyManager.tsx#L122-L143
const portsList = vm.ports as PortCreate[];
It reconstructs only the selected proxy. Other proxies remain read objects, after which the complete array is asserted as PortUpdate[].
For the selected proxy, customDomain is correctly sent as a string. However, an untouched proxy can still be sent like this:
{
"httpProxy": {
"name": "existing-proxy",
"url": "https://existing-proxy.vm-app.cloud.cbh.kth.se",
"customDomain": {
"domain": "existing.example.com",
"url": "https://existing.example.com",
"status": "active",
"secret": "..."
}
}
}
The entire update is then rejected because HttpProxyUpdate.customDomain expects a string.
ProxyManager also mutates objects from vm.ports directly before the request completes.
Error-handling cause
updateVM() parses the response but never checks its HTTP status:
https://github.com/kthcloud/console/blob/main/src/api/deploy/vms.ts#L68-L83
Conceptually it does:
const result = await response.json();
if (typeof result !== "object") throw new Error(...);
return result;
A validation error is an object, so it is returned as though the update succeeded.
Suggested fix
- Add an explicit
PortRead to PortUpdate mapper and remove the unsafe casts.
- Build new objects rather than mutating
vm.ports.
- Include only writable fields:
name, port, protocol, and writable httpProxy fields.
- For an unchanged existing proxy, send its
name but omit customDomain. The backend treats omission as unchanged. Re-sending an unchanged domain string may regenerate its verification secret and return it to pending state.
- Send a domain string only when it is explicitly added or changed; send the API's deletion representation only when explicitly removed.
- Make
updateVM() throw the parsed API error whenever !response.ok.
For example, preserving an existing proxy while updating the VM should produce an entry shaped like:
{
name: port.name,
port: port.port,
protocol: port.protocol,
httpProxy: port.httpProxy
? { name: port.httpProxy.name }
: undefined,
}
Acceptance criteria
- A second port can be added when another port has an active custom domain.
- Proxy/custom-domain settings can be changed when another proxy has an active custom domain.
- Existing custom domains retain their domain, verification status, and secret when unrelated ports are edited.
- Requests contain no read-only
externalPort, proxy url, custom-domain status, or custom-domain secret fields.
- Non-2xx responses show a useful error and are never passed to
queueJob().
- Tests cover both
PortManager and ProxyManager with an existing active custom-domain proxy.
Bug issue:
I wasn't able to create more port forwards after the first one, nor change its custom domain settings. It kept saying "saving..." but nothing happened.
I debugged with AI which gave the following writeup (I have confirmed this is the case by using the API directly which worked):
Summary
The VM port and proxy editors submit read-model port objects as update-model objects. This breaks updates when any existing port has an HTTP proxy with a custom domain.
The relevant API types are structurally different:
A TypeScript assertion is being used where an actual read-to-update conversion is required. Assertions do not transform the runtime object, so an untouched custom domain is sent as an object even though the API expects a string.
There is a second issue:
updateVM()does not checkresponse.ok. API validation errors are JSON objects, so they are treated as successful responses, passed toqueueJob(), and presented as "saving..." instead of showing an error.Steps to reproduce
Actual behavior
customDomainobject.Expected behavior
PortUpdatebefore submission.PortManager cause
PortManagerinitializes its state directly fromvm.ports, then adds the new port to that read-model array:https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/PortManager.tsx#L60-L108
The incompatible array is hidden with:
If an existing port has a custom domain, its
CustomDomainReadobject is serialized unchanged.ProxyManager cause
ProxyManageralso starts with the full read-model array:https://github.com/kthcloud/console/blob/main/src/pages/edit/vms/ProxyManager.tsx#L122-L143
It reconstructs only the selected proxy. Other proxies remain read objects, after which the complete array is asserted as
PortUpdate[].For the selected proxy,
customDomainis correctly sent as a string. However, an untouched proxy can still be sent like this:{ "httpProxy": { "name": "existing-proxy", "url": "https://existing-proxy.vm-app.cloud.cbh.kth.se", "customDomain": { "domain": "existing.example.com", "url": "https://existing.example.com", "status": "active", "secret": "..." } } }The entire update is then rejected because
HttpProxyUpdate.customDomainexpects a string.ProxyManageralso mutates objects fromvm.portsdirectly before the request completes.Error-handling cause
updateVM()parses the response but never checks its HTTP status:https://github.com/kthcloud/console/blob/main/src/api/deploy/vms.ts#L68-L83
Conceptually it does:
A validation error is an object, so it is returned as though the update succeeded.
Suggested fix
PortReadtoPortUpdatemapper and remove the unsafe casts.vm.ports.name,port,protocol, and writablehttpProxyfields.namebut omitcustomDomain. The backend treats omission as unchanged. Re-sending an unchanged domain string may regenerate its verification secret and return it to pending state.updateVM()throw the parsed API error whenever!response.ok.For example, preserving an existing proxy while updating the VM should produce an entry shaped like:
Acceptance criteria
externalPort, proxyurl, custom-domainstatus, or custom-domainsecretfields.queueJob().PortManagerandProxyManagerwith an existing active custom-domain proxy.