Add Frontend proxies tab to the HTTP routes page - #1258
Open
stephdl wants to merge 27 commits into
Open
Conversation
Add the action titles for get-trusted-proxies and set-trusted-proxies, the settings_http_routes keys of the new tab and a generic common.add label.
Let the operator set the frontend proxy addresses and the trust depth of a node. Accept bare IPv4 and IPv6 addresses only, since the traefik set-trusted-proxies action validates them with ipaddress.ip_address() and rejects CIDR ranges. Always send both proxies and depth, because omitting depth makes the action reset it to 1.
List the nodes running behind a frontend HTTP proxy, one row per node, by running get-trusted-proxies on every traefik instance. Nodes without any frontend proxy are hidden. Deleting a row sends an empty proxy list, which also resets the trust depth to 0.
Move the existing route table into a Routes tab and add a Frontend proxies tab. Track the selected tab in the view query parameter and pass the already discovered traefik instances down to the new tab.
Use the empty state wording of the design, drop the CIDR example and the word CIDR from the address field, since the traefik set-trusted-proxies action rejects CIDR ranges. Add the trust depth tooltip and the node placeholder, and remove the strings of the description paragraph and of the restart warning.
Hold the subpaths of the Carbon network--3 icon, which the design uses for the frontend proxies empty state. The library ships no routing pictogram.
Drop the node tooltip and add one to the trust depth, following the label markup of NsTextInput since cv-number-input has no tooltip slot. Widen the trust depth field so its helper text is no longer clipped, and remove the restart warning.
Add the filter row with the node dropdown and the clear filters link, and turn the table search off in favour of it. Show the add button as primary, inside the empty state when no node is configured and above the table otherwise. Drop the description paragraph and the restart warning.
One message for both the save and the delete path: applying either one restarts Traefik and drops the HTTP clients of the node.
Restore the restart warning: saving disconnects the HTTP clients of the node, and the design does not mention it. Stop waiting for the task completion event once the websocket comes back, since Traefik restarting means the action was applied and the event is gone with the old connection.
Traefik serves the cluster admin UI and its websocket, so restarting it makes the events of the running get-trusted-proxies tasks unreachable and the table stayed in its loading state until a manual page reload. Read the values again when the websocket comes back, and keep the counter and the rows consistent if an event is delivered after the reset. Restore the restart warning in the delete confirmation.
Reloading the page while Traefik restarts left the routes table stuck in its loading state, because the list-routes events were delivered to the closed websocket. Read the routes again when the websocket comes back.
Hide the node field when editing: it cannot be changed, the modal title already names the node, and a disabled combo box does not paint its value. Cap the notifications at the width of the form fields. Close as soon as the task is created, since the completion event cannot arrive before Traefik has restarted and the page has reconnected.
Carbon makes the search box and the combo box full width, so cap them to keep the design proportions. Leave the node filter empty until the user picks a value and reuse the sentinel of the routes table: NsComboBox resolves the label of a value only against the options loaded when it is set, so the preselected sentinel was painted as a raw value.
Keep one line per comment, like the surrounding views, and drop the ones the code already says.
The button was greyed out with no reason given once every node had a frontend proxy, which is the normal state of a single node cluster. Wrap it in a tooltip when there is nothing left to add, the same way the TLS certificates page does, and keep it plain while the values are still loading. Shorten the comments of this file too.
Carbon renders number inputs in monospace at 0.75rem, so the value looked much smaller than the addresses right above it.
NsComboBox repaints its text only when the new value matches an option, so clearing the value left the previously picked node on screen while the model was already empty. Remount it when the modal opens for an addition. Hide the restart warning until a node is picked, since it named an empty node.
Drop the trust depth reset from the delete confirmation: it is an implementation detail, and a depth without any proxy means nothing. Tighten the restart warning too.
24 tasks
The read counters were clamped with Math.max(0, n - 1) because a batch started on websocket reconnection ran while the events of the previous one were still arriving, and the surplus decrements could clear the loading state with an empty table. Tear the $root listeners down at the start of each read and on destroy, count the whole batch upfront and drop the clamps. This also stops a handler from running on a destroyed component. The first-connection guard is gone: it assumed the socket came up after the mount, but it is already connected when the page is opened, so the reconnection that follows a Traefik restart was swallowed and the tables stayed on their skeleton rows. A duplicated read on a cold load is harmless now that stale events cannot unbalance a counter. Release two loading states that stayed true for good: the list-installed-modules error branch, and the routes counter when the chain is aborted before it starts.
internalNodes was built once, inside listInstalledModulesCompleted, from the vuex clusterNodes. App.vue fills that state through get-cluster-status, which on a page reload usually answers after list-installed-modules: the list came out empty and both the node filter and the modal combo box had nothing to offer, while the table rows kept showing because they are built from the get-trusted-proxies output instead. Make it a computed of clusterNodes and traefikInstances so it no longer depends on which task answers first, and move the combo box repaint to a watcher that runs when the options actually exist.
7 tasks
NsComboBox fills its input from the options only when its value changes: the options watcher calls updateOptions(), which never recomputes the text from the current value. The filter started on an empty value, which matches no option, so the box kept its placeholder while the table behaved as if "Any node" were selected. Re-set the value once the options exist, the workaround the TLS certificates page already uses on its own node filter.
Carbon gives .bx--grid a max-width of 99rem plus auto margins at its largest breakpoint, so past 1584px the grid nested in the tab panel was capped and recentered: the filters, the button and the table all sat well inside the tile. Nothing shows below that width, which is why a 27" looks right and a 32" does not.
Same missing fullWidth as the frontend proxies tab, on the grid nested in the routes tab panel. This one predates the tabs, the grid was already nested in the tile.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Expose the Traefik trusted proxies settings in the cluster admin UI. Until now the
only way to configure them was
api-cli run module/traefik1/set-trusted-proxies.The HTTP routes page gets a tab bar: the existing route table moves into a
Routes tab, and a new Frontend proxies tab is added. The selected tab is
tracked in the
viewquery parameter, so reloading the page keeps it.The new tab lists one row per node running behind a frontend HTTP proxy, with its
proxy addresses and its trust depth. Nodes without any frontend proxy are hidden,
so a fresh cluster shows an empty state. Above the table sit a text filter, a node
filter and a Clear filters link. Values are read by running
get-trusted-proxieson every Traefik instance, one task per instance, the same way the Routes tab
already runs
list-routes.The Add/Edit modal takes the node, the proxy addresses (one per line) and the
trust depth. The Add dropdown only offers nodes that are not configured yet, since
set-trusted-proxiesreplaces the whole list of a node. Deleting a row sends anempty proxy list, which is what clears the configuration.
This is a UI-only change: no Traefik action, schema or configuration template is
touched.
Related issue
NethServer/dev#8098
Design
Built against the Figma source
(node 4597-32737),
frames
HTTP routes 2(empty state),HTTP routes 3(Add modal) andHTTP routes 9(populated table), plus the designNoteframe — which is wherethe trust depth tooltip requirement comes from.
Four deliberate deviations, all noted below: the CIDR wording, the tab label, the
empty state wording, and the restart warning the design does not show.
Two library gaps had to be filled.
ns8-ui-libships no routing pictogram, so thesubpaths of the Carbon
network--3icon used by the design live in a new localNetworkPictogram.vue— the repo's first local pictogram, following the shape ofthe library's own pictogram files. And
cv-number-inputhas no tooltip slot, sothe trust depth label reproduces the
label-with-tooltipmarkup ofNsTextInput.vue.Only bare IPv4 and IPv6 addresses are accepted, no CIDR
The design's textarea placeholder shows
10.0.0.0/24and its helper text readsEnter one IPv4, IPv6 or CIDR address per line. Neither was copied, because
the backend rejects CIDR ranges: the field offers
192.168.1.10and2001:db8::1as examples, and the helper text reads Enter one IPv4 or IPv6address per line.
The restriction comes from the action's own validation, in
ns8-traefik/imageroot/actions/set-trusted-proxies/20set_trusted_proxies#L29-L38:ipaddress.ip_address()only parses a bare host address, so a CIDR range exitswith
validation-failedbefore anything is written:Two notes about that check:
proxies.itemsis a plain{"type": "string"}with noformatand nopattern, so the constraint onlylives in that imperative Python.
entryPoints.<ep>.forwardedHeaders.trustedIPsdoes accept CIDR ranges inTraefik.
Accepting CIDR would mean switching to
ipaddress.ip_network(value, strict=False)in
ns8-traefik, which is out of scope here. The UI is wired for it either way:the modal maps the backend
bad_ip_addresscode in its-validation-failed-handler, so relaxing the action later only requires putting the
/nngroup backin the client-side patterns and updating the two strings.
Open question, needs a decision: should the UI allow a trust depth of 0?
Not settled, and deliberately left as is in this PR. Today the field mirrors the
action schema —
"depth": {"type": "integer", "minimum": 0}— so0is accepted, theAdd modal defaults to
1, and the table shows whatever is stored.Sending
0is accepted and applied, but it probably never does what an operatorexpects. With a non-empty proxy list,
20set_trusted_proxies#L23-L26stores
PROXIES_DEPTH=0—request.get('depth', 1)does not fall back to1, becausethe key is present.
What matters is that
trustedIPsanddepthare two independent mechanisms:forwardedHeaders.trustedIPsis set on the entrypoints, and it is what makes Traefikaccept the incoming
X-Forwarded-For. So the real client IP still reaches the accesslog with a depth of
0.depthonly feedsipAllowList.ipStrategy.depth(
set-route/20writeconfig#L96-L99),so it only affects routes that carry an
ip_allowlist.For those routes, a depth of
0means the allowlist matches the upstream proxyaddress instead of the real client, so a
sourceRangewritten for client IPs silentlystops doing what it says. Traefik documents a depth of
0or less as making the fieldignored, falling back to the direct peer; that part was read from the documentation and
not verified against a running instance.
There is a second, less visible effect:
restore-module/60restore_settings#L20gates the whole restore on
proxies_depth > 0, so a valid configuration with a depthof 0 is never restored from a backup — it reports "Trusted proxies configuration was
not found in the backup. Nothing to do." even though
trustedIPsare present. The testshould look at the proxy list, not at the depth. That is an
ns8-traefikbug, listedin the follow-up below.
So the practical position is that a depth of at least
1is what operators want. Twothings to agree on before changing anything:
1, keeping the field adjustable so a chain ofseveral proxies is still configurable — which is what the design's
-/+stepperand issue Configure Traefik behind frontend HTTP proxy dev#8098 both imply — or is a depth other than
1never usefulin practice, in which case the field could go away entirely and
1be sent always?1is enforced, what should the Edit modal do with an existingconfiguration that already stores
0, set throughapi-clior before the change?Repairing it to
1on open makes saving fix the configuration, but reverses theearlier "show the depth as returned by the backend" decision; showing
0with aninline error is more faithful but opens the modal in an invalid state.
Either way the UI would become more restrictive than the backend, which keeps
accepting
0throughapi-cli. That is a defensible choice, just one worth makingexplicitly rather than by accident.
Two inconsistencies inside the design, resolved by majority
other frame. Kept Frontend proxies.
proxy configured on frames 2 and 9. Kept the frontend proxy wording, which
is consistent with the tab name and with the user-facing vocabulary of the
feature; trusted proxies stays the backend term.
Surviving the Traefik restart
set-trusted-proxiesends withsystemctl --user restart traefik.service, andTraefik is what serves the cluster admin UI and its websocket
(
ns8-traefikcreate-module/60cluster_adminroutes/cluster-admin). So saving afrontend proxy drops the admin's own connection — which the design does not mention,
but testing on a real cluster made unmissable.
Two consequences, both handled here.
The restart is announced. The
Traefik will be restartedwarning is back in theAdd/Edit modal and in the delete confirmation, naming the node and saying the page
will reconnect on its own. This is a deliberate deviation from the design, and it is
what the route and certificate flows already do.
The modal closes as soon as the task is created, like the delete-route flow
already does, instead of spinning until the page reconnects. The completion event
cannot arrive any earlier — Traefik carries the websocket it would travel on — so
waiting for it only showed a spinner for the whole outage. The task notification
reports the outcome, and the table refreshes on reconnection.
Lost task events no longer wedge the page. The loading counters of this UI are
driven purely by websocket task events (
loading.listRoutesNum,loading.getTrustedProxiesNum,loading.setTrustedProxies). When the socket diesmid-task the completion event is lost, the counter never returns to zero, and the
table stays on its skeleton rows until the browser is reloaded by hand. The three
components now watch the Vuex
isWebsocketConnectedflag — whichApp.vuealreadymaintains and which no component used until now — and on reconnection they reset the
counter and read their data again. Decrements are clamped with
Math.max(0, n - 1)and rows are keyed by node (routes by Traefik instance) so an event delivered after a
reset can neither unbalance the counter nor duplicate a row. The modal simply stops
spinning and closes: Traefik having restarted is the proof the action was applied.
This also fixes the same wedge on the Routes tab, which was reachable by reloading
the page while Traefik was restarting.
One known limitation is left untouched, because it is pre-existing and belongs to the
shared websocket layer rather than to this feature:
vue-native-websocketis set upwith both
connectManually: trueandreconnection: true(src/main.js:24-28), sothere are two independent reconnect drivers.
$connect()builds a newObserveronevery call and abandons the previous one, which keeps retrying on its own
(
Main.js:16-20,Observer.js:41-55), whileApp.vue:415-421schedules anotherinitWebSocket()after each disconnect. Since everyObserveremits into the samesingleton
Emitter, the accumulated ones keep re-triggering App.vue's connect anddisconnect handlers, which is why the page appeared to reload endlessly. Fixing that
is a one-line change but it touches plumbing every page depends on and entangled with
login, logout, session expiry and core updates, so it deserves its own change and its
own testing. With the handling above the symptom is contained: the page recovers its
data by itself, no manual reload needed.
Two other backend behaviours the UI works around
depthon a non-empty proxy list makes the action reset it to1(
20set_trusted_proxies#L24),so the modal always sends both
proxiesanddepth. Editing only the addresslist no longer silently loses a trust depth of, say,
3.get-trusted-proxiesbuilds the list from a Pythonset()(
20get_trusted_proxies#L17-L20),so the order is not stable between calls. The UI sorts the addresses to keep the
display consistent across reloads.
How to test
On a cluster with at least two nodes, go to
https://<leader>/cluster-admin/#/settings/http-routes.create/edit/delete/detail modals. The URL keeps
?view=routesand reloadingrestores the tab.
pictogram, No frontend proxy configured, and a single blue
Add frontend proxy button inside the card — no button above the table.
192.168.1.10on node 1 with trust depth1. The row appears and theAdd frontend proxy button moves above the table. Reload: the values persist.
Cross-check with
api-cli run module/traefik1/get-trusted-proxies— it returns{"proxies": ["192.168.1.10"], "depth": 1}.2001:db8::1. The row updates. The Editmodal shows no Node field — the node cannot be changed and the title already names
it.
192.168.1.10,10.0.0.5,2001:db8::1.All three are listed and stored.
10.0.0.0/24: inline error Invalid IPv4 address: 10.0.0.0/24, saveblocked, focus moves to the field. Then
2001:db8::/32gives the IPv6 message,and
notanipthe IPv4 one.3, save, reload: still3. Then-1is rejected inline,and an empty value reports Required. The
(i)next to Trust depth explainsthe hop count, and Number of frontend proxy levels renders in full under the
field.
3saved, edit only the address list and save. Reload and check thedepth is still
3.2. Node 1 is unchanged: thesetting is per node.
filter matching nothing shows the no-results state with its own
Clear filters button.
get-trusted-proxieson node 1returns
{"proxies": [], "depth": 0}, and node 2 is untouched.dropdown. Once every node has a frontend proxy the button is disabled and carries a
tooltip saying so — on a single node cluster that is the state right after the first
configuration, and without the tooltip it reads as a broken button.
journalctl --user -u traefikon a configurednode logs the real client IP instead of the proxy IP.
traefik.serviceon one node and load the tab: the table shows the errorstate and the other node's row still renders.
The restart handling needs one specific scenario: configure a frontend proxy on the
node your browser is connected to, since configuring any other node does not drop
your session.
websocket drops and reconnects, and the table must refresh on its own with the new
row, with no skeleton left. No manual reload.
reconnection.
tab and the Frontend proxies tab must end up populated, neither stuck on skeleton
rows.
The connected/disconnected toasts may still churn for a while — that is the websocket
layer limitation described above, untouched. What matters is that the page recovers
its data without intervention.
Automated checks
core/uihas notestscript and no jest/vitest configuration, so no unit testwas added for the address and trust depth validation. Lint, the production build
and an audit of the i18n keys referenced by the touched components all pass:
yarn lintneeds the binary path becausevue-cli-serviceis not on the Yarn 3PATH, and the build needs
--openssl-legacy-providerbecause webpack 4 uses md4on Node 22. Both are pre-existing and unrelated to this change.
Reused components
No new UI component was written apart from the pictogram noted above. The tab bar
is
NsTabs, followingsrc/components/domains/DomainUsersAndGroups.vue. Thefilter row, the two-branch empty state and the primary button inside the empty
state follow
src/views/settings/SettingsTlsCertificates.vue. The table, emptystate, modal and delete confirmation are
NsDataTable,NsEmptyState,NsModaland
NsDangerDeleteModalfromns8-ui-lib. The IPv4 and IPv6 patterns come fromsrc/components/settings/CreateOrEditHttpRouteModal.vue, with the CIDR groupremoved.
Follow-up in ns8-traefik, out of scope here
Found while reading the actions, none of them touched by this PR:
set-trusted-proxiesrejects CIDR ranges although Traefik'strustedIPsacceptsthem — see the section above. Would need
ipaddress.ip_network(value, strict=False).restore-module/60restore_settings:20gates the restore onproxies_depth > 0, so avalid configuration with a depth of
0is never restored from a backup.get-trusted-proxies/validate-output.json:3-4hastitleand$idsayingset-trusted-proxies— copy/paste slip.get-trusted-proxiesbuilds the list withlist(set(...)), so the order it returnsis not stable. The UI sorts to work around it.
Dependencies
None.