diff --git a/core/ui/package.json b/core/ui/package.json index 71f901a85..215791a80 100644 --- a/core/ui/package.json +++ b/core/ui/package.json @@ -16,7 +16,7 @@ "@carbon/icons-vue": "^10.37.0", "@carbon/themes": "^10.34.0", "@carbon/vue": "^2.40.0", - "@nethserver/ns8-ui-lib": "^1.12.4", + "@nethserver/ns8-ui-lib": "^2.0.1", "await-to-js": "^3.0.0", "axios": "^0.33.0", "carbon-components": "^10.41.0", diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index 8330ac4fc..dea07ecbb 100644 --- a/core/ui/public/i18n/en/translation.json +++ b/core/ui/public/i18n/en/translation.json @@ -18,6 +18,7 @@ "clear_search": "Clear search", "settings": "Settings", "cancel": "Cancel", + "add": "Add", "node": "Node", "nodes_c": "Node | Nodes", "version": "Version", @@ -402,6 +403,8 @@ "set-route": "Set route", "delete-route": "Delete route", "list-routes": "List routes", + "get-trusted-proxies": "Retrieve frontend proxies", + "set-trusted-proxies": "Configure frontend proxies", "set-certificate": "Set certificate", "set-default-certificate": "Request certificate", "delete-certificate": "Delete certificate", @@ -844,7 +847,29 @@ "show_logs": "Show logs", "traefik_will_be_restarted": "Traefik will be restarted", "delete_route_with_certificate_message": "Deleting this route will briefly disconnect HTTP clients connected to {node}.", - "revoke_certificate_message": "Revoking the route certificate will briefly disconnect HTTP clients connected to {node}." + "revoke_certificate_message": "Revoking the route certificate will briefly disconnect HTTP clients connected to {node}.", + "routes": "Routes", + "frontend_proxies": "Frontend proxies", + "proxies": "Frontend proxies", + "depth": "Trust depth", + "add_frontend_proxy": "Add frontend proxy", + "add_frontend_proxy_disabled_message": "All cluster nodes already have a frontend proxy", + "add_frontend_proxy_no_node_message": "No cluster node with a Traefik instance is available", + "edit_frontend_proxy_node": "Edit frontend proxies of {node}", + "delete_frontend_proxy_node": "Delete frontend proxies of {node}", + "delete_frontend_proxy_description": "Delete the frontend proxies of {node}? The real client IP won't be read from the X-Forwarded-For header anymore.", + "no_frontend_proxy": "No frontend proxy configured", + "no_frontend_proxy_description": "Add frontend proxy addresses to correctly identify client IPs behind reverse proxies or load balancers.", + "filter_frontend_proxies": "Filter", + "choose_node": "Choose node", + "frontend_proxies_placeholder": "e.g. 192.168.1.10\n2001:db8::1", + "frontend_proxies_helper": "Enter one IPv4 or IPv6 address per line", + "trust_depth": "Trust depth", + "trust_depth_helper": "Number of frontend proxy levels", + "trust_depth_tooltip": "Number of frontend proxies between the client and Traefik. With a trust depth of 1, the client IP address is read from the last entry of the X-Forwarded-For header.", + "trust_depth_invalid": "Enter a whole number greater than or equal to 0", + "bad_ip_address": "Invalid IP address. Enter one IPv4 or IPv6 address per line, without CIDR prefix", + "frontend_proxy_restart_message": "HTTP clients connected to {node} will be briefly disconnected. If your session goes through this node, the page reconnects on its own." }, "settings_tls_certificates": { "title": "TLS certificates", diff --git a/core/ui/src/components/settings/ConfigureFrontendProxyModal.vue b/core/ui/src/components/settings/ConfigureFrontendProxyModal.vue new file mode 100644 index 000000000..3f09a6b52 --- /dev/null +++ b/core/ui/src/components/settings/ConfigureFrontendProxyModal.vue @@ -0,0 +1,406 @@ + + + + {{ + isEditing + ? $t("settings_http_routes.edit_frontend_proxy_node", { + node: proxyConfig ? proxyConfig.nodeLabel : "", + }) + : $t("settings_http_routes.add_frontend_proxy") + }} + + + + + + + + + + {{ $t("settings_http_routes.trust_depth") }} + + + + {{ $t("settings_http_routes.trust_depth_tooltip") }} + + + + + + + + + + + + {{ $t("common.cancel") }} + {{ + isEditing ? $t("common.save") : $t("common.add") + }} + + + + + + diff --git a/core/ui/src/components/settings/HttpFrontendProxies.vue b/core/ui/src/components/settings/HttpFrontendProxies.vue new file mode 100644 index 000000000..633b86f71 --- /dev/null +++ b/core/ui/src/components/settings/HttpFrontendProxies.vue @@ -0,0 +1,677 @@ + + + + + + + + + + + + + + {{ $t("common.clear_filters") }} + + + + + + + + {{ $t("settings_http_routes.add_frontend_proxy") }} + + + {{ $t("settings_http_routes.add_frontend_proxy") }} + + + + + + + + + + + + + + + + + {{ $t("common.no_search_results_description") }} + + {{ $t("common.clear_filters") }} + + + + + + + + + + + + + + + {{ + $t( + "settings_http_routes.no_frontend_proxy_description" + ) + }} + + {{ $t("settings_http_routes.add_frontend_proxy") }} + + + + {{ $t("settings_http_routes.add_frontend_proxy") }} + + + + + + + + + + + {{ row.node }} + + + + + {{ proxy }} + + + + + {{ row.depth }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/views/settings/SettingsHttpRoutes.vue b/core/ui/src/views/settings/SettingsHttpRoutes.vue index 28c3e1898..db2f3ce4a 100644 --- a/core/ui/src/views/settings/SettingsHttpRoutes.vue +++ b/core/ui/src/views/settings/SettingsHttpRoutes.vue @@ -40,6 +40,7 @@ - - - - - - - - - {{ $t("settings_http_routes.create_route") }} - - - - - - - - + + + + + + + - - - {{ - $t( - "settings_http_routes.no_http_route_description" - ) - }} - - - - - - + + + + + {{ $t("settings_http_routes.create_route") }} + + + + + + - - - - {{ row.host }}{{ row.path }} - - - {{ row.host || row.path }} - - - - - - + + + + {{ $t( - "settings_http_routes.cannot_obtain_tls_certificate" + "settings_http_routes.no_http_route_description" ) }} - - {{ $t("settings_http_routes.show_logs") }} - - - - - - - - {{ row.name }} - - - - - {{ $t(`settings_http_routes.${row.type}`) }} - - - - {{ row.node }} - - - - - - - + + + + + - - + + + {{ row.host }}{{ row.path }} + + + {{ row.host || row.path }} + + + + + + + {{ + $t( + "settings_http_routes.cannot_obtain_tls_certificate" + ) + }} + + {{ $t("settings_http_routes.show_logs") }} + + + + + + + + {{ row.name }} + + + + + {{ $t(`settings_http_routes.${row.type}`) }} + + + + {{ row.node }} + + + - - - - - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -327,14 +358,19 @@ import { import { mapState } from "vuex"; import HttpRouteDetailModal from "@/components/settings/HttpRouteDetailModal.vue"; import CreateOrEditHttpRouteModal from "@/components/settings/CreateOrEditHttpRouteModal.vue"; +import HttpFrontendProxies from "@/components/settings/HttpFrontendProxies.vue"; import _cloneDeep from "lodash/cloneDeep"; import WarningAltFilled16 from "@carbon/icons-vue/es/warning--alt--filled/16"; +// tab order: the view query param is the tab selector +const VIEWS = ["routes", "frontend-proxies"]; + export default { name: "SettingsHttpRoutes", components: { HttpRouteDetailModal, CreateOrEditHttpRouteModal, + HttpFrontendProxies, WarningAltFilled16, }, mixins: [ @@ -351,12 +387,12 @@ export default { data() { return { q: { + view: "", selectedNodeId: "", }, tablePage: [], tableColumns: ["route", "name", "type", "node", "attributes"], routes: [], - internalNodes: [], isShownCreateOrEditRouteModal: false, isShownRouteDetailModal: false, isShownDeleteRouteModal: false, @@ -366,6 +402,8 @@ export default { currentRoute: null, routeToDelete: null, isEditingRoute: false, + // [eventName, handler] pairs registered on $root for the read chain + readListeners: [], pendingCertificatesLogsPath: {}, loading: { listInstalledModules: false, @@ -380,7 +418,7 @@ export default { }; }, computed: { - ...mapState(["clusterNodes"]), + ...mapState(["clusterNodes", "isWebsocketConnected"]), i18nTableColumns() { return this.tableColumns.map((column) => { return this.$t("settings_http_routes." + column); @@ -414,6 +452,27 @@ export default { } return ""; }, + // clusterNodes can land after list-installed-modules, on a page reload it + // usually does: recompute the list instead of snapshotting it once + internalNodes() { + return this.clusterNodes.map((node) => { + const nodeId = node.id.toString(); + const traefikInstance = this.traefikInstances.find( + (instance) => instance.node === nodeId + ); + const internalNode = { + name: nodeId, + label: this.getShortNodeLabel(node), + value: nodeId, + }; + + if (traefikInstance) { + internalNode.label += ` (${traefikInstance.id})`; + internalNode.traefikInstance = traefikInstance.id; + } + return internalNode; + }); + }, nodesForFilter() { if (!this.internalNodes.length) { return []; @@ -430,6 +489,23 @@ export default { return nodes; }, }, + watch: { + // the filter has no option to select until both clusterNodes and the + // instances landed, in either order + internalNodes: function (internalNodes) { + if (internalNodes.length) { + this.repaintNodeFilter(); + } + }, + isWebsocketConnected: function (isConnected) { + // a Traefik restart kills this websocket: pending task events are lost. + // Always restart from the instance list, it chains everything else and + // republishes traefikInstances for the frontend proxies tab + if (isConnected) { + this.listInstalledModules(); + } + }, + }, beforeRouteEnter(to, from, next) { next((vm) => { vm.watchQueryData(vm); @@ -443,7 +519,22 @@ export default { created() { this.listInstalledModules(); }, + beforeDestroy() { + // a task completing after destroy would re-fire the whole read chain + this.clearListeners(this.readListeners); + }, + mounted() { + // the tabs have no default selection: an unknown view would leave them all closed + if (!VIEWS.includes(this.q.view)) { + this.q.view = "routes"; + } + }, methods: { + tabSelected(tabNum) { + if (VIEWS[tabNum]) { + this.q.view = VIEWS[tabNum]; + } + }, showCreateRouteModal() { this.isEditingRoute = false; this.isShownCreateOrEditRouteModal = true; @@ -470,19 +561,50 @@ export default { hideDeleteRouteModal() { this.isShownDeleteRouteModal = false; }, + repaintNodeFilter() { + this.$nextTick(() => { + if (!this.q.selectedNodeId) { + // initially show all nodes + this.q.selectedNodeId = "all"; + return; + } + const nodeId = this.q.selectedNodeId; + + // cv-combo-box only repaints its text when the value changes + this.q.selectedNodeId = ""; + this.$nextTick(() => { + this.q.selectedNodeId = nodeId; + }); + }); + }, + registerListener(listeners, eventName, handler) { + this.$root.$once(eventName, handler); + listeners.push([eventName, handler]); + }, + clearListeners(listeners) { + listeners.forEach(([eventName, handler]) => { + this.$root.$off(eventName, handler); + }); + listeners.splice(0); + }, async listInstalledModules() { + // a handler of a previous chain would decrement the counter of this one + this.clearListeners(this.readListeners); this.loading.listInstalledModules = true; + this.error.listInstalledModules = ""; const taskAction = "list-installed-modules"; const eventId = this.getUuid(); // register to task error - this.$root.$once( + this.registerListener( + this.readListeners, `${taskAction}-aborted-${eventId}`, this.listInstalledModulesAborted ); // register to task completion - this.$root.$once( + this.registerListener( + this.readListeners, `${taskAction}-completed-${eventId}`, this.listInstalledModulesCompleted ); @@ -503,7 +625,8 @@ export default { this.error.listInstalledModules = errMessage; this.currentErrorAction = this.$t("action." + taskAction); this.currentErrorDescription = errMessage; - return; + // otherwise the tables keep their skeleton rows for good + this.loading.listInstalledModules = false; } }, listInstalledModulesAborted(taskResult, taskContext) { @@ -512,73 +635,47 @@ export default { this.currentErrorAction = this.$t("action." + taskContext.action); this.currentErrorDescription = this.$t("error.generic_error"); this.loading.listInstalledModules = false; + // the routes chain will not run: release its skeleton rows + this.loading.listRoutesNum = 0; }, listInstalledModulesCompleted(taskContext, taskResult) { - // init nodes - let nodes = []; - - for (let node of this.clusterNodes) { - nodes.push({ - name: node.id.toString(), - label: this.getShortNodeLabel(node), - value: node.id.toString(), - }); - } - let traefikInstances = []; for (let instanceList of Object.values(taskResult.output)) { for (let instance of instanceList) { if (instance.id.startsWith("traefik")) { traefikInstances.push(instance); - - // update nodes labels - const node = nodes.find((node) => node.value === instance.node); - - if (node) { - node.label += ` (${instance.id})`; - node.traefikInstance = instance.id; - } } } } - this.internalNodes = nodes; this.traefikInstances = traefikInstances; this.loading.listInstalledModules = false; - - this.$nextTick(() => { - if (!this.q.selectedNodeId) { - // initially show all nodes - this.q.selectedNodeId = "all"; - } else { - const nodeId = this.q.selectedNodeId; - - // workaround to update combo box - this.q.selectedNodeId = ""; - this.$nextTick(() => { - this.q.selectedNodeId = nodeId; - }); - } - }); - this.listRoutes(); }, async listRoutes() { this.routes = []; + // otherwise a transient error sticks after the nodes answer again + this.error.listRoutes = ""; + this.currentErrorAction = ""; + this.currentErrorDescription = ""; + // count the whole batch upfront: the counter must not reach zero between + // two iterations + this.loading.listRoutesNum = this.traefikInstances.length; for (const traefikInstance of this.traefikInstances) { const taskAction = "list-routes"; const eventId = this.getUuid(); - this.loading.listRoutesNum++; // register to task events - this.$root.$once( + this.registerListener( + this.readListeners, `${taskAction}-aborted-${eventId}`, this.listRoutesAborted ); - this.$root.$once( + this.registerListener( + this.readListeners, `${taskAction}-completed-${eventId}`, this.listRoutesCompleted ); @@ -647,7 +744,9 @@ export default { route.traefikInstance = traefikId; routes.push(route); } + // drop the previous routes of this instance: a late event must not duplicate them this.routes = this.routes + .filter((route) => route.traefikInstance !== traefikId) .concat(routes) .sort(this.sortByProperty("name")); this.loading.listRoutesNum--; diff --git a/core/ui/yarn.lock b/core/ui/yarn.lock index fae5d8163..169a8b507 100644 --- a/core/ui/yarn.lock +++ b/core/ui/yarn.lock @@ -3289,9 +3289,9 @@ __metadata: languageName: node linkType: hard -"@nethserver/ns8-ui-lib@npm:^1.12.4": - version: 1.12.4 - resolution: "@nethserver/ns8-ui-lib@npm:1.12.4" +"@nethserver/ns8-ui-lib@npm:^2.0.1": + version: 2.0.1 + resolution: "@nethserver/ns8-ui-lib@npm:2.0.1" dependencies: "@rollup/plugin-json": ^4.1.0 core-js: ^3.15.2 @@ -3300,7 +3300,7 @@ __metadata: vue-date-fns: ^2.0.1 peerDependencies: vue: ^2.6.12 - checksum: 319c09ec851a7391c49da77f1849d09a40da199349e5d797d1dd6d09b688a316f6bcc4cacd0018dc7a2348ccda9f155bf25ceb981720e854dde85490f8c28257 + checksum: 1f87dc7ae9994d8c2ebe6348714f900f39096975be4f24af10b2adfa1c84e53b8782ae379d3c71b19873361d46a1f426cb8db23f7d69ae749059f3db138520a6 languageName: node linkType: hard @@ -14771,7 +14771,7 @@ __metadata: "@carbon/icons-vue": ^10.37.0 "@carbon/themes": ^10.34.0 "@carbon/vue": ^2.40.0 - "@nethserver/ns8-ui-lib": ^1.12.4 + "@nethserver/ns8-ui-lib": ^2.0.1 "@storybook/addon-actions": ^6.2.9 "@storybook/addon-essentials": ^6.2.9 "@storybook/addon-links": ^6.2.9
+ {{ $t("common.no_search_results_description") }} +
+ {{ + $t( + "settings_http_routes.no_frontend_proxy_description" + ) + }} +