diff --git a/api/specs/web-server/_resource_usage.py b/api/specs/web-server/_resource_usage.py index 5df12b4807d..8c4b36b3c01 100644 --- a/api/specs/web-server/_resource_usage.py +++ b/api/specs/web-server/_resource_usage.py @@ -73,8 +73,8 @@ async def list_resource_usage_services( ) -@router.post( - "/services/-/resource-usages:export", +@router.get( + "/services/-/usage-report", status_code=status.HTTP_302_FOUND, responses={ status.HTTP_302_FOUND: { diff --git a/services/static-webserver/client/.eslintrc.json b/services/static-webserver/client/.eslintrc.json index e128edb014a..39bbadd185f 100644 --- a/services/static-webserver/client/.eslintrc.json +++ b/services/static-webserver/client/.eslintrc.json @@ -39,7 +39,8 @@ "semi": "off", "comma-dangle": "off", "object-curly-spacing": "off", - "no-implicit-coercion": "off" + "no-implicit-coercion": "off", + "arrow-body-style": "off" }, "env": { "browser": true diff --git a/services/static-webserver/client/source/class/osparc/Preferences.js b/services/static-webserver/client/source/class/osparc/Preferences.js index d52b9e8b7e7..415d6eb9cd1 100644 --- a/services/static-webserver/client/source/class/osparc/Preferences.js +++ b/services/static-webserver/client/source/class/osparc/Preferences.js @@ -122,6 +122,13 @@ qx.Class.define("osparc.Preferences", { check: "Boolean", event: "changeAllowMetricsCollection", apply: "__patchPreference" + }, + + billingCenterUsageColumnOrder: { + nullable: true, + check: "Array", + event: "changeBillingCenterUsageColumnOrder", + apply: "__patchPreference" } }, diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index c7e33a113eb..11ff1399840 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -263,16 +263,15 @@ qx.Class.define("osparc.data.Resources", { "resourceUsage": { useCache: false, endpoints: { - getPage: { + get: { method: "GET", - url: statics.API + "/services/-/resource-usages?offset={offset}&limit={limit}" - } - } - }, - "resourceUsagePerWallet": { - useCache: false, - endpoints: { - getPage: { + url: statics.API + "/services/-/resource-usages?offset={offset}&limit={limit}&filters={filters}&order_by={orderBy}" + }, + getWithWallet: { + method: "GET", + url: statics.API + "/services/-/resource-usages?wallet_id={walletId}&offset={offset}&limit={limit}&filters={filters}&order_by={orderBy}" + }, + getWithWallet2: { method: "GET", url: statics.API + "/services/-/resource-usages?wallet_id={walletId}&offset={offset}&limit={limit}" } @@ -735,7 +734,7 @@ qx.Class.define("osparc.data.Resources", { endpoints: { get: { method: "GET", - url: statics.API + "/wallets/-/payments" + url: statics.API + "/wallets/-/payments?offset={offset}&limit={limit}" }, startPayment: { method: "POST", diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/AutoRecharge.js b/services/static-webserver/client/source/class/osparc/desktop/credits/AutoRecharge.js index 079c5f1278d..10153748cfa 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/AutoRecharge.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/AutoRecharge.js @@ -12,19 +12,19 @@ Authors: * Odei Maiz (odeimaiz) + * Ignacio Pascual ************************************************************************ */ qx.Class.define("osparc.desktop.credits.AutoRecharge", { - extend: qx.ui.core.Widget, + extend: qx.ui.container.Stack, construct: function(walletId) { this.base(arguments); - this._setLayout(new qx.ui.layout.VBox(15)); - const store = osparc.store.Store.getInstance(); const wallet = store.getWallets().find(w => w.getWalletId() == walletId); + this.__buildLayout(); this.setWallet(wallet); }, @@ -48,40 +48,38 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { __topUpAmountField: null, __monthlyLimitField: null, __paymentMethodField: null, - __topUpAmountHelper: null, - - _createChildControlImpl: function(id) { - let control; - switch (id) { - case "auto-recharge-description": - control = new qx.ui.basic.Label().set({ - value: this.tr("Keep your balance running smoothly by automatically setting your credits to be recharged when it runs low."), - font: "text-14", - rich: true, - wrap: true - }); - this._add(control); - break; - case "auto-recharge-form": - control = this.__getAutoRechargeForm(); - this._add(control); - break; - case "buttons-layout-2": - control = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)); - this._add(control); - break; - case "save-auto-recharge-button": - control = this.__getSaveAutoRechargeButton(); - this.getChildControl("buttons-layout-2").add(control); - break; - } - return control || this.base(arguments, id); - }, __buildLayout: function() { - this.getChildControl("auto-recharge-description"); - this.getChildControl("auto-recharge-form"); - this.getChildControl("save-auto-recharge-button"); + this.removeAll() + + this.__mainContent = new qx.ui.container.Composite(new qx.ui.layout.VBox(15).set({ + alignX: "center" + })) + const title = new qx.ui.basic.Label("Auto-recharge").set({ + marginTop: 25, + font: "title-18" + }); + const subtitle = new qx.ui.basic.Label("Keep your balance running smoothly by automatically setting your credits to be recharged when it runs low.").set({ + rich: true, + font: "text-14", + textAlign: "center" + }); + this.__mainContent.add(title); + this.__mainContent.add(subtitle); + this.__mainContent.add(this.__getAutoRechargeForm()) + this.__mainContent.add(this.__getButtons()) + this.add(this.__mainContent) + + this.__fetchingView = new qx.ui.container.Composite(new qx.ui.layout.VBox().set({ + alignX: "center", + alignY: "middle" + })) + const image = new qx.ui.basic.Image("@FontAwesome5Solid/circle-notch/26") + image.getContentElement().addClass("rotate") + this.__fetchingView.add(image) + this.add(this.__fetchingView) + + this.setSelection([this.__fetchingView]) }, __applyWallet: function(wallet) { @@ -100,6 +98,8 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { const paymentMethodSB = this.__paymentMethodField; await osparc.desktop.credits.Utils.populatePaymentMethodSelector(wallet, paymentMethodSB); + this.setSelection([this.__fetchingView]) + // populate the form const params = { url: { @@ -114,7 +114,6 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { __populateForm: function(arData) { this.__enabledField.setValue(arData.enabled); this.__topUpAmountField.setValue(arData["topUpAmountInUsd"]); - this.__topUpAmountHelper.setValue(this.tr(`When your account reaches ${arData["minBalanceInUsd"]} credits, it gets recharged by this amount`)); if (arData["monthlyLimitInUsd"]) { this.__monthlyLimitField.setValue(arData["monthlyLimitInUsd"] > 0 ? arData["monthlyLimitInUsd"] : 0); } else { @@ -125,52 +124,57 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { if (paymentMethodFound) { paymentMethodSB.setSelection([paymentMethodFound]); } + this.setSelection([this.__mainContent]) }, __getAutoRechargeForm: function() { - const autoRechargeLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(15)); - + const autoRechargeLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(10).set({ + alignX: "center" + })).set({ + marginTop: 20 + }); - const enabledLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); - const enabledTitle = new qx.ui.basic.Label().set({ - value: this.tr("ENABLED"), - font: "text-14" + const enabledLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)).set({ + allowStretchX: false, + width: 274 + }); + const enabledTitle = new qx.ui.basic.Label(this.tr("Enabled")).set({ + marginLeft: 2 + }) + const enabledCheckbox = this.__enabledField = new qx.ui.form.CheckBox().set({ + appearance: "appmotion-buy-credits-checkbox" }); - const enabledCheckbox = this.__enabledField = new qx.ui.form.CheckBox(); enabledLayout.add(enabledTitle); enabledLayout.add(enabledCheckbox); autoRechargeLayout.add(enabledLayout); - - const topUpAmountLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + const topUpAmountLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)).set({ + allowStretchX: false, + marginTop: 15 + }); const topUpAmountTitleLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)); - const topUpAmountTitle = new qx.ui.basic.Label().set({ - value: this.tr("RECHARGING AMOUNT (US$)"), - font: "text-14" + const topUpAmountTitle = new qx.ui.basic.Label(this.tr("Recharging amount (USD)")).set({ + marginLeft: 15 }); topUpAmountTitleLayout.add(topUpAmountTitle); - const topUpAmountInfo = new osparc.ui.hint.InfoHint("Amount in US$ payed when auto-recharge condition is satisfied."); + const topUpAmountInfo = new osparc.ui.hint.InfoHint("Amount in USD payed when auto-recharge condition is satisfied."); topUpAmountTitleLayout.add(topUpAmountInfo); topUpAmountLayout.add(topUpAmountTitleLayout); const topUpAmountField = this.__topUpAmountField = new qx.ui.form.Spinner().set({ minimum: 10, maximum: 10000, - maxWidth: 200 + width: 300, + appearance: "appmotion-buy-credits-spinner" }); topUpAmountLayout.add(topUpAmountField); - const topUpAmountHelper = this.__topUpAmountHelper = new qx.ui.basic.Label().set({ - font: "text-12", - rich: true, - wrap: true - }); - topUpAmountLayout.add(topUpAmountHelper); autoRechargeLayout.add(topUpAmountLayout); - const monthlyLimitLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + const monthlyLimitLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)).set({ + allowStretchX: false + }); const monthlyLimitTitleLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)); - const monthlyLimitTitle = new qx.ui.basic.Label().set({ - value: this.tr("MONTHLY LIMIT (US$)"), - font: "text-14" + const monthlyLimitTitle = new qx.ui.basic.Label(this.tr("Monthly limit (USD)")).set({ + marginLeft: 15 }); monthlyLimitTitleLayout.add(monthlyLimitTitle); const monthlyLimitTitleInfo = new osparc.ui.hint.InfoHint(this.tr("Maximum amount in US$ charged within a natural month.")); @@ -179,31 +183,32 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { const monthlyLimitField = this.__monthlyLimitField = new qx.ui.form.Spinner().set({ minimum: 0, maximum: 100000, - maxWidth: 200 + width: 300, + appearance: "appmotion-buy-credits-spinner" }); monthlyLimitLayout.add(monthlyLimitField); - const monthlyLimitHelper = new qx.ui.basic.Label().set({ - value: this.tr("To disable spending limit, clear input field"), + const monthlyLimitHelper = new qx.ui.basic.Label(this.tr("To disable spending limit, clear input field")).set({ font: "text-12", - rich: true, - wrap: true + marginLeft: 15, + rich: true }); monthlyLimitLayout.add(monthlyLimitHelper); autoRechargeLayout.add(monthlyLimitLayout); - const paymentMethodLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); - const paymentMethodTitle = new qx.ui.basic.Label().set({ - value: this.tr("PAY WITH"), - font: "text-14" + const paymentMethodLayout = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)).set({ + allowStretchX: false + }); + const paymentMethodTitle = new qx.ui.basic.Label(this.tr("Pay with")).set({ + marginLeft: 15 }); paymentMethodLayout.add(paymentMethodTitle); const paymentMethodField = this.__paymentMethodField = new qx.ui.form.SelectBox().set({ - minWidth: 200, - maxWidth: 200 + width: 300, + appearance: "appmotion-buy-credits-select" }); paymentMethodLayout.add(paymentMethodField); const addNewPaymentMethod = new qx.ui.basic.Label(this.tr("Add Payment Method")).set({ - padding: 0, + marginLeft: 15, cursor: "pointer", font: "link-label-12" }); @@ -211,6 +216,16 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { paymentMethodLayout.add(addNewPaymentMethod); autoRechargeLayout.add(paymentMethodLayout); + enabledCheckbox.bind("value", monthlyLimitField, "enabled") + enabledCheckbox.bind("value", paymentMethodField, "enabled") + enabledCheckbox.bind("value", topUpAmountField, "enabled") + enabledCheckbox.bind("value", monthlyLimitHelper, "visibility", { + converter: value => value ? "visible" : "hidden" + }) + enabledCheckbox.bind("value", addNewPaymentMethod, "visibility", { + converter: value => value ? "visible" : "hidden" + }) + return autoRechargeLayout; }, @@ -243,17 +258,25 @@ qx.Class.define("osparc.desktop.credits.AutoRecharge", { .finally(() => fetchButton.setFetching(false)); }, - __getSaveAutoRechargeButton: function() { - const saveAutoRechargeBtn = new osparc.ui.form.FetchButton().set({ - label: this.tr("Save and close"), - font: "text-14", - appearance: "strong-button", - maxWidth: 200, - center: true + __getButtons: function() { + const btnContainer = new qx.ui.container.Composite(new qx.ui.layout.HBox(10).set({ + alignX: "center" + })).set({ + marginTop: 30, + marginBottom: 15 + }); + const saveAutoRechargeBtn = new osparc.ui.form.FetchButton(this.tr("Save and close")).set({ + appearance: "appmotion-button-action" }); const successfulMsg = this.tr("Changes on the Auto recharge were successfully saved"); saveAutoRechargeBtn.addListener("execute", () => this.__updateAutoRecharge(this.__enabledField.getValue(), saveAutoRechargeBtn, successfulMsg)); - return saveAutoRechargeBtn; + btnContainer.add(saveAutoRechargeBtn) + const cancelBtn = new qx.ui.form.Button("Cancel").set({ + appearance: "appmotion-button" + }); + cancelBtn.addListener("execute", () => this.fireEvent("close")) + btnContainer.add(cancelBtn) + return btnContainer; } } }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/BillingCenter.js b/services/static-webserver/client/source/class/osparc/desktop/credits/BillingCenter.js index 5b0d4fba1c3..e8f258a7248 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/BillingCenter.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/BillingCenter.js @@ -114,21 +114,6 @@ qx.Class.define("osparc.desktop.credits.BillingCenter", { return page; }, - __getBuyCreditsPage: function() { - const title = this.tr("Buy Credits"); - const iconSrc = "@FontAwesome5Solid/dollar-sign/22"; - const page = new osparc.desktop.preferences.pages.BasePage(title, iconSrc); - page.showLabelOnTab(); - const buyCredits = this.__buyCredits = new osparc.desktop.credits.BuyCredits(); - buyCredits.set({ - margin: 10 - }); - buyCredits.addListener("addNewPaymentMethod", () => this.openPaymentMethods(true), this); - buyCredits.addListener("transactionCompleted", () => this.openTransactions(true), this); - page.add(buyCredits); - return page; - }, - __getTransactionsPage: function() { const title = this.tr("Transactions"); const iconSrc = "@FontAwesome5Solid/exchange-alt/22"; @@ -138,7 +123,7 @@ qx.Class.define("osparc.desktop.credits.BillingCenter", { transactions.set({ margin: 10 }); - page.add(transactions); + page.add(transactions, { flex: 1 }); return page; }, @@ -151,7 +136,7 @@ qx.Class.define("osparc.desktop.credits.BillingCenter", { usage.set({ margin: 10 }); - page.add(usage); + page.add(usage, { flex: 1 }); return page; }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCredits.js b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCredits.js deleted file mode 100644 index 5a5251fcd34..00000000000 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCredits.js +++ /dev/null @@ -1,139 +0,0 @@ -/* ************************************************************************ - - osparc - the simcore frontend - - https://osparc.io - - Copyright: - 2023 IT'IS Foundation, https://itis.swiss - - License: - MIT: https://opensource.org/licenses/MIT - - Authors: - * Odei Maiz (odeimaiz) - -************************************************************************ */ - -qx.Class.define("osparc.desktop.credits.BuyCredits", { - extend: qx.ui.core.Widget, - - construct: function() { - this.base(arguments); - - this._setLayout(new qx.ui.layout.VBox(15)); - - const store = osparc.store.Store.getInstance(); - store.bind("contextWallet", this, "contextWallet"); - }, - - properties: { - contextWallet: { - check: "osparc.data.model.Wallet", - init: null, - nullable: false, - event: "changeContextWallet", - apply: "__buildLayout" - } - }, - - events: { - "addNewPaymentMethod": "qx.event.type.Event", - "transactionCompleted": "qx.event.type.Event" - }, - - members: { - _createChildControlImpl: function(id) { - let control; - switch (id) { - case "credits-intro": - control = this.__getCreditsExplanation(); - this._add(control); - break; - case "payment-mode-layout": - control = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)); - this._add(control); - break; - case "payment-mode-title": - control = new qx.ui.basic.Label(this.tr("Payment mode")).set({ - font: "text-14" - }); - this.getChildControl("payment-mode-layout").add(control); - break; - case "payment-mode": { - this.getChildControl("payment-mode-title"); - control = new qx.ui.form.SelectBox().set({ - allowGrowX: false, - allowGrowY: false - }); - const autoItem = new qx.ui.form.ListItem(this.tr("Automatic"), null, "automatic"); - control.add(autoItem); - const manualItem = new qx.ui.form.ListItem(this.tr("Manual"), null, "manual"); - control.add(manualItem); - this.getChildControl("payment-mode-layout").add(control); - break; - } - case "one-time-payment": - control = new osparc.desktop.credits.OneTimePayment().set({ - margin: 10, - maxWidth: 400 - }); - this.bind("contextWallet", control, "wallet"); - control.addListener("addNewPaymentMethod", () => this.fireEvent("addNewPaymentMethod")); - control.addListener("transactionCompleted", () => this.fireEvent("transactionCompleted")); - this._add(control); - break; - case "auto-recharge": - control = new osparc.desktop.credits.AutoRecharge().set({ - margin: 10, - maxWidth: 400 - }); - control.addListener("addNewPaymentMethod", () => this.fireEvent("addNewPaymentMethod")); - this.bind("contextWallet", control, "wallet"); - this._add(control); - break; - } - return control || this.base(arguments, id); - }, - - __buildLayout: function() { - this._removeAll(); - this._createChildControlImpl("credits-intro"); - const wallet = this.getContextWallet(); - if (wallet.getMyAccessRights()["write"]) { - this._createChildControlImpl("wallet-billing-settings"); - const paymentMode = this._createChildControlImpl("payment-mode"); - const autoRecharge = this._createChildControlImpl("auto-recharge"); - const oneTime = this._createChildControlImpl("one-time-payment"); - autoRecharge.show(); - oneTime.exclude(); - paymentMode.addListener("changeSelection", e => { - const model = e.getData()[0].getModel(); - if (model === "manual") { - autoRecharge.exclude(); - oneTime.show(); - } else { - autoRecharge.show(); - oneTime.exclude(); - } - }); - } else { - this._add(osparc.desktop.credits.Utils.getNoWriteAccessOperationsLabel()); - } - }, - - __getCreditsExplanation: function() { - const layout = new qx.ui.container.Composite(new qx.ui.layout.VBox(20)); - - const label = new qx.ui.basic.Label().set({ - value: "Explain here what a Credit is and what one can run/do with them.", - font: "text-14", - rich: true, - wrap: true - }); - layout.add(label); - - return layout; - } - } -}); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsForm.js b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsForm.js index 9f187412019..6492b7f1a54 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsForm.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsForm.js @@ -19,7 +19,9 @@ qx.Class.define("osparc.desktop.credits.BuyCreditsForm", { font: "title-18" }); const subtitle = new qx.ui.basic.Label("A one-off, non recurring payment.").set({ - font: "text-14" + rich: true, + font: "text-14", + textAlign: "center" }); this._add(title); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsInput.js b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsInput.js index bb3e1606589..d4f121b9241 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsInput.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsInput.js @@ -62,7 +62,6 @@ qx.Class.define("osparc.desktop.credits.BuyCreditsInput", { })) const input = new qx.ui.form.TextField().set({ appearance: "appmotion-buy-credits-input", - decorator: "appmotion-buy-credits-input", textAlign: "center", width: 80, ...inputProps diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsStepper.js b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsStepper.js index e6141de2183..4ad8f07e952 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsStepper.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/BuyCreditsStepper.js @@ -58,7 +58,9 @@ qx.Class.define("osparc.desktop.credits.BuyCreditsStepper", { osparc.data.Resources.fetch("payments", "startPayment", params) .then(data => { const { paymentId, paymentFormUrl } = data; - this.__iframe = new qx.ui.embed.Iframe(paymentFormUrl); + this.__iframe = new qx.ui.embed.Iframe(paymentFormUrl).set({ + decorator: "no-border-2" + }); this.add(this.__iframe); osparc.wrapper.WebSocket.getInstance().getSocket().once("paymentCompleted", wsData => { const paymentData = JSON.parse(wsData); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/CurrentUsage.js b/services/static-webserver/client/source/class/osparc/desktop/credits/CurrentUsage.js index 243922015d1..1d303405901 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/CurrentUsage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/CurrentUsage.js @@ -66,7 +66,7 @@ qx.Class.define("osparc.desktop.credits.CurrentUsage", { limit: 10 } }; - osparc.data.Resources.fetch("resourceUsagePerWallet", "getPage", params) + osparc.data.Resources.fetch("resourceUsage", "getWithWallet2", params) .then(data => { const currentTasks = data.filter(d => (d.project_id === currentStudy.getUuid()) && d.service_run_status === "RUNNING"); let cost = 0; diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/DateFilters.js b/services/static-webserver/client/source/class/osparc/desktop/credits/DateFilters.js index 7f2d7654cc7..b661a16b45f 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/DateFilters.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/DateFilters.js @@ -7,18 +7,24 @@ qx.Class.define("osparc.desktop.credits.DateFilters", { extend: qx.ui.core.Widget, + construct() { this.base(arguments); this._setLayout(new qx.ui.layout.HBox(7)); this._buildLayout(); }, + events: { "change": "qx.event.type.Data" }, + members: { _buildLayout() { this._removeAll(); - this.__from = this.__addDateInput("From"); + const defaultFrom = new Date() + defaultFrom.setMonth(defaultFrom.getMonth() - 1) + // Range defaults to previous month + this.__from = this.__addDateInput("From", defaultFrom); this.__until = this.__addDateInput("Until"); const lastWeekBtn = new qx.ui.form.Button("Last week").set({ allowStretchY: false, @@ -57,17 +63,19 @@ qx.Class.define("osparc.desktop.credits.DateFilters", { }) this._add(lastYearBtn); }, - __addDateInput(label) { + + __addDateInput(label, initDate) { const container = new qx.ui.container.Composite(new qx.ui.layout.VBox()); const lbl = new qx.ui.basic.Label(label); container.add(lbl); const datepicker = new qx.ui.form.DateField(); - datepicker.setValue(new Date()); + datepicker.setValue(initDate ? initDate : new Date()); datepicker.addListener("changeValue", e => this._changeHandler(e)); container.add(datepicker); this._add(container); return datepicker; }, + _changeHandler(e) { const timestampFrom = this.__from.getValue().getTime(); const timestampUntil = this.__until.getValue().getTime(); @@ -87,6 +95,15 @@ qx.Class.define("osparc.desktop.credits.DateFilters", { from, until }); + }, + + getValue() { + const from = osparc.utils.Utils.formatDateYyyyMmDd(this.__from.getValue()) + const until = osparc.utils.Utils.formatDateYyyyMmDd(this.__until.getValue()) + return { + from, + until + } } } }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/Transactions.js b/services/static-webserver/client/source/class/osparc/desktop/credits/Transactions.js index cbeccb3f2ab..4228977d0c0 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/Transactions.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/Transactions.js @@ -41,17 +41,6 @@ qx.Class.define("osparc.desktop.credits.Transactions", { }, members: { - _createChildControlImpl: function(id) { - let control; - switch (id) { - case "transactions-table": - control = new osparc.desktop.credits.TransactionsTable(); - this._add(control); - break; - } - return control || this.base(arguments, id); - }, - __buildLayout: function() { this._removeAll(); @@ -63,52 +52,36 @@ qx.Class.define("osparc.desktop.credits.Transactions", { }); this._add(this.__introLabel); - const filterContainer = new qx.ui.container.Composite(new qx.ui.layout.HBox()) - this.__dateFilters = new osparc.desktop.credits.DateFilters(); - this.__dateFilters.addListener("change", e => this.__saveFilters(e.getData())); - filterContainer.add(this.__dateFilters); - - filterContainer.add(new qx.ui.core.Spacer(), { - flex: 1 - }); - - this.__exportButton = new qx.ui.form.Button(this.tr("Export")).set({ - allowStretchY: false, - alignY: "bottom" - }); - this.__exportButton.addListener("execute", () => { - console.log("export", this.__params); - }); - filterContainer.add(this.__exportButton); - // FEATURE TOGGLE + // const filterContainer = new qx.ui.container.Composite(new qx.ui.layout.HBox()) + // this.__dateFilters = new osparc.desktop.credits.DateFilters(); + // this.__dateFilters.addListener("change", e => this.__saveFilters(e.getData())); + // filterContainer.add(this.__dateFilters); + + // filterContainer.add(new qx.ui.core.Spacer(), { + // flex: 1 + // }); + + // this.__exportButton = new qx.ui.form.Button(this.tr("Export")).set({ + // allowStretchY: false, + // alignY: "bottom" + // }); + // this.__exportButton.addListener("execute", () => { + // console.log("export", this.__params); + // }); + // filterContainer.add(this.__exportButton); + // this._add(filterContainer); - const wallet = this.__personalWallet; - if (wallet && wallet.getMyAccessRights()["write"]) { - const transactionsTable = this._createChildControlImpl("transactions-table"); - osparc.data.Resources.fetch("payments", "get") - .then(transactions => { - if ("data" in transactions) { - transactionsTable.addData(transactions["data"]); - } - }) - .catch(err => console.error(err)); + this.__table = new osparc.desktop.credits.TransactionsTable().set({ + marginTop: 10 + }) + if (this.__personalWallet && this.__personalWallet.getMyAccessRights()["write"]) { + this._add(this.__table, { flex: 1 }) + this.__table.getTableModel().reloadData() } else { - this._add(osparc.desktop.credits.Utils.getNoWriteAccessInformationLabel()); + this._add(osparc.desktop.credits.Utils.getNoWriteAccessInformationLabel()) } - }, - - refresh: function() { - console.log(this.__params); - }, - - __saveFilters: function(filters) { - this.__params = { - ...this.__params, - filters - }; - this.refresh(); } } }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTable.js b/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTable.js index 49a7460b046..4f253366c5e 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTable.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTable.js @@ -12,122 +12,34 @@ Authors: * Odei Maiz (odeimaiz) + * Ignacio Pascual (ignapas) ************************************************************************ */ qx.Class.define("osparc.desktop.credits.TransactionsTable", { - extend: osparc.ui.table.Table, + extend: qx.ui.table.Table, construct: function() { - const model = new qx.ui.table.model.Simple(); - const cols = this.self().COLUMNS; - const colNames = Object.values(cols).map(col => col.title); - model.setColumns(colNames); - - this.base(arguments, model, { - tableColumnModel: obj => new qx.ui.table.columnmodel.Resize(obj), - statusBarVisible: false - }); + this.base(arguments) + const model = new osparc.desktop.credits.TransactionsTableModel(); + this.setTableModel(model) + this.setStatusBarVisible(false) const columnModel = this.getTableColumnModel(); - columnModel.setDataCellRenderer(cols.credits.pos, new qx.ui.table.cellrenderer.Number()); - columnModel.setDataCellRenderer(cols.status.pos, new qx.ui.table.cellrenderer.Html()); - columnModel.setDataCellRenderer(cols.invoice.pos, new qx.ui.table.cellrenderer.Html()); - this.setColumnWidth(cols.invoice.pos, 50); - this.makeItLoose(); - }, - - statics: { - COLUMNS: { - date: { - pos: 0, - title: qx.locale.Manager.tr("Date") - }, - price: { - pos: 1, - title: qx.locale.Manager.tr("Price USD") - }, - credits: { - pos: 2, - title: qx.locale.Manager.tr("Credits") - }, - status: { - pos: 3, - title: qx.locale.Manager.tr("Status") - }, - comment: { - pos: 4, - title: qx.locale.Manager.tr("Comment") - }, - invoice: { - pos: 5, - title: qx.locale.Manager.tr("Invoice") - } - }, - - addColorTag: function(status) { - const color = this.getLevelColor(status); - status = osparc.utils.Utils.onlyFirstsUp(status); - return ("" + status + ""); - }, - - getLevelColor: function(status) { - const colorManager = qx.theme.manager.Color.getInstance(); - let logLevel = null; - switch (status) { - case "SUCCESS": - logLevel = "info"; - break; - case "PENDING": - logLevel = "warning"; - break; - case "CANCELED": - case "FAILED": - logLevel = "error"; - break; - default: - console.error("completedStatus unknown"); - break; - } - return colorManager.resolve("logger-"+logLevel+"-message"); - }, - - createPdfIconWithLink: function(link) { - return `Invoice`; - }, - - respDataToTableRow: function(data) { - const cols = this.COLUMNS; - const newData = []; - newData[cols["date"].pos] = osparc.utils.Utils.formatDateAndTime(new Date(data["createdAt"])); - newData[cols["price"].pos] = data["priceDollars"] ? data["priceDollars"].toFixed(2) : 0; - newData[cols["credits"].pos] = data["osparcCredits"] ? data["osparcCredits"].toFixed(2) : 0; - if (data["completedStatus"]) { - newData[cols["status"].pos] = this.addColorTag(data["completedStatus"]); - } - newData[cols["comment"].pos] = data["comment"]; - const invoiceUrl = data["invoiceUrl"]; - newData[cols["invoice"].pos] = invoiceUrl? this.createPdfIconWithLink(invoiceUrl) : ""; - return newData; - }, + columnModel.setColumnWidth(0, 130); + columnModel.setColumnWidth(1, 70); + columnModel.setColumnWidth(2, 70); + columnModel.setColumnWidth(3, 70); + columnModel.setColumnWidth(4, 320); + columnModel.setColumnWidth(5, 60); - respDataToTableData: function(datas) { - const newDatas = []; - if (datas) { - datas.forEach(data => { - const newData = this.respDataToTableRow(data); - newDatas.push(newData); - }); - } - return newDatas; - } - }, + columnModel.setDataCellRenderer(1, new qx.ui.table.cellrenderer.Number()); + columnModel.setDataCellRenderer(2, new qx.ui.table.cellrenderer.Number()); + columnModel.setDataCellRenderer(3, new qx.ui.table.cellrenderer.Html()); + columnModel.setDataCellRenderer(5, new qx.ui.table.cellrenderer.Html()); - members: { - addData: function(datas) { - const newDatas = this.self().respDataToTableData(datas); - this.setData(newDatas); - } + this.setHeaderCellHeight(26); + this.setRowHeight(26); } }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTableModel.js b/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTableModel.js new file mode 100644 index 00000000000..a6a40fb3972 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/TransactionsTableModel.js @@ -0,0 +1,154 @@ +/* + * oSPARC - The SIMCORE frontend - https://osparc.io + * Copyright: 2024 IT'IS Foundation - https://itis.swiss + * License: MIT - https://opensource.org/licenses/MIT + * Authors: Ignacio Pascual (ignapas) + */ +const SERVER_MAX_LIMIT = 49 + +qx.Class.define("osparc.desktop.credits.TransactionsTableModel", { + extend: qx.ui.table.model.Remote, + + construct() { + this.base(arguments) + this.setColumns([ + qx.locale.Manager.tr("Date"), + qx.locale.Manager.tr("Price USD"), + qx.locale.Manager.tr("Credits"), + qx.locale.Manager.tr("Status"), + qx.locale.Manager.tr("Comment"), + qx.locale.Manager.tr("Invoice") + ], [ + "date", + "price", + "credits", + "status", + "comment", + "invoice" + ]) + this.setColumnSortable(0, false) + this.setColumnSortable(1, false) + this.setColumnSortable(2, false) + this.setColumnSortable(3, false) + this.setColumnSortable(4, false) + this.setColumnSortable(5, false) + }, + + properties: { + walletId: { + check: "Number", + nullable: false + }, + filters: { + check: "Object", + init: null + }, + isFetching: { + check: "Boolean", + init: false, + event: "changeFetching" + } + }, + + members: { + // overridden + _loadRowCount() { + osparc.data.Resources.fetch("payments", "get", { + url: { + limit: 1, + offset: 0 + } + }, undefined, { + resolveWResponse: true + }) + .then(({ data: resp }) => { + this._onRowCountLoaded(resp["_meta"].total) + }) + .catch(() => { + this._onRowCountLoaded(null) + }) + }, + // overridden + _loadRowData(firstRow, qxLastRow) { + this.setIsFetching(true) + // Please Qloocloox don't ask for more rows than there are + const lastRow = Math.min(qxLastRow, this._rowCount - 1) + const getFetchPromise = (offset, limit=SERVER_MAX_LIMIT) => { + return osparc.data.Resources.fetch("payments", "get", { + url: { + limit, + offset + } + }) + .then(({ data: rawData }) => { + const data = [] + rawData.forEach(rawRow => { + data.push({ + date: osparc.utils.Utils.formatDateAndTime(new Date(rawRow.createdAt)), + price: rawRow.priceDollars ? rawRow.priceDollars.toFixed(2) : 0, + credits: rawRow.osparcCredits ? rawRow.osparcCredits.toFixed(2) * 1 : 0, + status: this.__addColorTag(rawRow.completedStatus), + comment: rawRow.comment, + invoice: rawRow.invoiceUrl ? this.__createPdfIconWithLink(rawRow.invoiceUrl) : "" + }) + }) + return data + }) + } + // Divides the model row request into several server requests to comply with the number of rows server limit + const reqLimit = lastRow - firstRow + 1 // Number of requested rows + const nRequests = Math.ceil(reqLimit / SERVER_MAX_LIMIT) + if (nRequests > 1) { + let requests = [] + for (let i=firstRow; i <= lastRow; i += SERVER_MAX_LIMIT) { + requests.push(getFetchPromise(i, i > lastRow - SERVER_MAX_LIMIT + 1 ? reqLimit % SERVER_MAX_LIMIT : SERVER_MAX_LIMIT)) + } + Promise.all(requests) + .then(responses => { + this._onRowDataLoaded(responses.flat()) + }) + .catch(err => { + console.error(err) + this._onRowDataLoaded(null) + }) + .finally(() => this.setIsFetching(false)) + } else { + getFetchPromise(firstRow, reqLimit) + .then(data => { + this._onRowDataLoaded(data) + }) + .catch(err => { + console.error(err) + this._onRowDataLoaded(null) + }) + .finally(() => this.setIsFetching(false)) + } + }, + __getLevelColor: function(status) { + const colorManager = qx.theme.manager.Color.getInstance(); + let logLevel = null; + switch (status) { + case "SUCCESS": + logLevel = "info"; + break; + case "PENDING": + logLevel = "warning"; + break; + case "CANCELED": + case "FAILED": + logLevel = "error"; + break; + default: + console.error("completedStatus unknown"); + break; + } + return colorManager.resolve(`logger-${logLevel}-message`); + }, + __addColorTag: function(status) { + return `${osparc.utils.Utils.onlyFirstsUp(status)}`; + }, + __createPdfIconWithLink: function(link) { + return `Invoice`; + } + } +}) diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/Usage.js b/services/static-webserver/client/source/class/osparc/desktop/credits/Usage.js index 1413b6ef8ed..b3e344976c8 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/Usage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/Usage.js @@ -12,6 +12,7 @@ Authors: * Odei Maiz (odeimaiz) + * Ignacio Pascual (ignapas) ************************************************************************ */ @@ -29,71 +30,15 @@ qx.Class.define("osparc.desktop.credits.Usage", { this.__buildLayout() }, - statics: { - ITEMS_PER_PAGE: 15 - }, - members: { - __prevRequestParams: null, - __nextRequestParams: null, - - _createChildControlImpl: function(id) { - let control; - switch (id) { - case "usage-table": - control = new osparc.desktop.credits.UsageTable().set({ - height: (this.self().ITEMS_PER_PAGE*20 + 40) - }); - this._add(control); - break; - case "page-buttons": - control = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)).set({ - allowGrowX: true, - alignX: "center", - alignY: "middle" - }); - this._add(control); - break; - case "prev-page-button": { - control = new qx.ui.form.Button().set({ - icon: "@FontAwesome5Solid/chevron-left/12", - allowGrowX: false - }); - control.addListener("execute", () => this.__fetchData(this.__getPrevRequest())); - const pageButtons = this.getChildControl("page-buttons"); - pageButtons.add(control); - break; - } - case "current-page-label": { - control = new qx.ui.basic.Label().set({ - font: "text-14", - textAlign: "center", - alignY: "middle" - }); - const pageButtons = this.getChildControl("page-buttons"); - pageButtons.add(control); - break; - } - case "next-page-button": { - control = new qx.ui.form.Button().set({ - icon: "@FontAwesome5Solid/chevron-right/12", - allowGrowX: false - }); - control.addListener("execute", () => this.__fetchData(this.__getNextRequest())); - const pageButtons = this.getChildControl("page-buttons"); - pageButtons.add(control); - break; - } - } - return control || this.base(arguments, id); - }, - __buildLayout: function() { this._removeAll(); const container = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + const lbl = new qx.ui.basic.Label("Select a credit account:"); container.add(lbl); + const selectBoxContainer = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)); const walletSelectBox = new qx.ui.form.SelectBox().set({ allowStretchX: false, @@ -109,9 +54,13 @@ qx.Class.define("osparc.desktop.credits.Usage", { this.__fetchingImg.getContentElement().addClass("rotate"); selectBoxContainer.add(this.__fetchingImg); container.add(selectBoxContainer); + const filterContainer = new qx.ui.container.Composite(new qx.ui.layout.HBox()) this.__dateFilters = new osparc.desktop.credits.DateFilters(); - this.__dateFilters.addListener("change", e => console.log(e.getData())); + this.__dateFilters.addListener("change", e => { + this.__table.getTableModel().setFilters(e.getData()) + this.__table.getTableModel().reloadData() + }); filterContainer.add(this.__dateFilters); filterContainer.add(new qx.ui.core.Spacer(), { flex: 1 @@ -121,95 +70,54 @@ qx.Class.define("osparc.desktop.credits.Usage", { alignY: "bottom" }); this.__exportButton.addListener("execute", () => { - console.log("export"); + this.__handleExport() }); filterContainer.add(this.__exportButton); - // FEATURE TOGGLE - // container.add(filterContainer); + container.add(filterContainer); + this._add(container); + walletSelectBox.addListener("changeSelection", e => { if (walletSelectBox.getSelection().length) { - const selectedWallet = walletSelectBox.getSelection()[0].getModel(); - this.__selectedWallet = selectedWallet; - this.__fetchData(); + this.__selectedWallet = walletSelectBox.getSelection()[0].getModel() + if (this.__table) { + this.__table.getTableModel().setWalletId(this.__selectedWallet.getWalletId()) + this.__table.getTableModel().reloadData() + } else { + // qx: changeSelection is triggered after the first item is added to SelectBox + this.__table = new osparc.desktop.credits.UsageTable(this.__selectedWallet.getWalletId(), this.__dateFilters.getValue()).set({ + marginTop: 10 + }) + this.__table.getTableModel().bind("isFetching", this.__fetchingImg, "visibility", { + converter: isFetching => isFetching ? "visible" : "excluded" + }) + container.add(this.__table, { flex: 1 }) + } } }); - this.__userWallets.forEach(wallet => { - walletSelectBox.add(new qx.ui.form.ListItem(wallet.getName(), null, wallet)); - }); - }, - __fetchData: function(request) { - this.__fetchingImg.show(); - - if (request === undefined) { - request = this.__getNextRequest(); - } - request - .then(resp => { - const data = resp["data"]; - this.__setData(data); - this.__prevRequestParams = resp["_links"]["prev"]; - this.__nextRequestParams = resp["_links"]["next"]; - this.__evaluatePageButtons(resp); - }) - .finally(() => { - this.__fetchingImg.exclude(); + if (osparc.desktop.credits.Utils.areWalletsEnabled()) { + this.__userWallets.forEach(wallet => { + walletSelectBox.add(new qx.ui.form.ListItem(wallet.getName(), null, wallet)); }); - }, - - __getPrevRequest: function() { - const params = { - url: { - offset: this.self().ITEMS_PER_PAGE, - limit: this.self().ITEMS_PER_PAGE - } - }; - if (this.__prevRequestParams) { - params.url.offset = osparc.utils.Utils.getParamFromURL(this.__prevRequestParams, "offset"); - params.url.limit = osparc.utils.Utils.getParamFromURL(this.__prevRequestParams, "limit"); - } - return this.__getCommonRequest(params); - }, - - __getNextRequest: function() { - const params = { - url: { - offset: 0, - limit: this.self().ITEMS_PER_PAGE - } - }; - if (this.__nextRequestParams) { - params.url.offset = osparc.utils.Utils.getParamFromURL(this.__nextRequestParams, "offset"); - params.url.limit = osparc.utils.Utils.getParamFromURL(this.__nextRequestParams, "limit"); - } - return this.__getCommonRequest(params); - }, - - __getCommonRequest: function(params) { - const options = { - resolveWResponse: true - }; - - const selectedWallet = this.__selectedWallet; - if (selectedWallet) { - const walletId = selectedWallet.getWalletId(); - params.url["walletId"] = walletId.toString(); - return osparc.data.Resources.fetch("resourceUsagePerWallet", "getPage", params, undefined, options); + } else { + lbl.setVisibility("excluded") + walletSelectBox.setVisibility("excluded") + this.__exportButton.setVisibility("excluded") + this.__table = new osparc.desktop.credits.UsageTable(null, this.__dateFilters.getValue()).set({ + marginTop: 10 + }) + this.__table.getTableModel().bind("isFetching", this.__fetchingImg, "visibility", { + converter: isFetching => isFetching ? "visible" : "excluded" + }) + container.add(this.__table, { flex: 1 }) } - // Usage supports the non wallet enabled products - return osparc.data.Resources.fetch("resourceUsage", "getPage", params, undefined, options); - }, - - __setData: function(data) { - const table = this.getChildControl("usage-table"); - table.addData(data); }, - - __evaluatePageButtons:function(resp) { - this.getChildControl("prev-page-button").setEnabled(Boolean(this.__prevRequestParams)); - this.getChildControl("current-page-label").setValue(((resp["_meta"]["offset"]/this.self().ITEMS_PER_PAGE)+1).toString()); - this.getChildControl("next-page-button").setEnabled(Boolean(this.__nextRequestParams)); + __handleExport() { + const reportUrl = new URL("/v0/services/-/usage-report", window.location.origin) + reportUrl.searchParams.append("wallet_id", this.__selectedWallet.getWalletId()) + reportUrl.searchParams.append("filters", JSON.stringify({ "started_at": this.__dateFilters.getValue() })) + window.open(reportUrl, "_blank") } } }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTable.js b/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTable.js index c4c3be15ea9..06798f25d56 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTable.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTable.js @@ -12,117 +12,62 @@ Authors: * Odei Maiz (odeimaiz) + * Ignacio Pascual (ignapas) ************************************************************************ */ qx.Class.define("osparc.desktop.credits.UsageTable", { - extend: osparc.ui.table.Table, + extend: qx.ui.table.Table, - construct: function() { - const model = new qx.ui.table.model.Simple(); - const cols = this.self().COLUMNS; - const colNames = Object.values(cols).map(col => col.title); - model.setColumns(colNames); + construct: function(walletId, filters) { + this.base(arguments) + const model = new osparc.desktop.credits.UsageTableModel(walletId, filters) + this.setTableModel(model) + this.setStatusBarVisible(false) - this.base(arguments, model, { - tableColumnModel: obj => new qx.ui.table.columnmodel.Resize(obj), - statusBarVisible: false - }); - this.makeItLoose(); + this.setHeaderCellHeight(26); + this.setRowHeight(26); const columnModel = this.getTableColumnModel(); - columnModel.getBehavior().setWidth(cols.duration.pos, 70); - columnModel.getBehavior().setWidth(cols.status.pos, 70); - columnModel.getBehavior().setWidth(cols.cost.pos, 60); - columnModel.setDataCellRenderer(cols.cost.pos, new qx.ui.table.cellrenderer.Number()); + columnModel.setDataCellRenderer(6, new qx.ui.table.cellrenderer.Number()); if (!osparc.desktop.credits.Utils.areWalletsEnabled()) { - columnModel.setColumnVisible(cols.cost.pos, false); - columnModel.setColumnVisible(cols.user.pos, false); + columnModel.setColumnVisible(6, false); + columnModel.setColumnVisible(7, false); } - }, - - statics: { - COLUMNS: { - project: { - pos: 0, - title: osparc.product.Utils.getStudyAlias({firstUpperCase: true}) - }, - node: { - pos: 1, - title: qx.locale.Manager.tr("Node") - }, - service: { - pos: 2, - title: qx.locale.Manager.tr("Service") - }, - start: { - pos: 3, - title: qx.locale.Manager.tr("Start") - }, - duration: { - pos: 4, - title: qx.locale.Manager.tr("Duration") - }, - status: { - pos: 5, - title: qx.locale.Manager.tr("Status") - }, - cost: { - pos: 6, - title: qx.locale.Manager.tr("Credits") - }, - user: { - pos: 7, - title: qx.locale.Manager.tr("User") - } - }, - - respDataToTableRow: async function(data) { - const cols = this.COLUMNS; - const newData = []; - newData[cols["project"].pos] = data["project_name"] ? data["project_name"] : data["project_id"]; - newData[cols["node"].pos] = data["node_name"] ? data["node_name"] : data["node_id"]; - if (data["service_key"]) { - const parts = data["service_key"].split("/"); - const serviceName = parts.pop(); - newData[cols["service"].pos] = serviceName + ":" + data["service_version"]; - } - if (data["started_at"]) { - const startTime = new Date(data["started_at"]); - newData[cols["start"].pos] = osparc.utils.Utils.formatDateAndTime(startTime); - if (data["stopped_at"]) { - const stopTime = new Date(data["stopped_at"]); - const durationTime = stopTime - startTime; - newData[cols["duration"].pos] = osparc.utils.Utils.formatMilliSeconds(durationTime); - } - } - newData[cols["status"].pos] = qx.lang.String.firstUp(data["service_run_status"].toLowerCase()); - newData[cols["cost"].pos] = data["credit_cost"] ? data["credit_cost"].toFixed(2) : "-"; - const user = await osparc.store.Store.getInstance().getUser(data["user_id"]); - if (user) { - newData[cols["user"].pos] = user ? user["label"] : data["user_id"]; - } - return newData; - }, - - respDataToTableData: async function(datas) { - const newDatas = []; - if (datas) { - for (const data of datas) { - const newData = await this.respDataToTableRow(data); - newDatas.push(newData); - } - } - return newDatas; + columnModel.setColumnVisible(2, false) + + // Array [0, 1, ..., N] where N is column_count - 1 (default column order) + this.__columnOrder = [...Array(columnModel.getOverallColumnCount()).keys()] + + if ( + osparc.Preferences.getInstance().getBillingCenterUsageColumnOrder() && + osparc.Preferences.getInstance().getBillingCenterUsageColumnOrder().length === this.__columnOrder.length + ) { + columnModel.setColumnsOrder(osparc.Preferences.getInstance().getBillingCenterUsageColumnOrder()) + this.__columnOrder = osparc.Preferences.getInstance().getBillingCenterUsageColumnOrder() + } else { + osparc.Preferences.getInstance().setBillingCenterUsageColumnOrder(this.__columnOrder) } - }, - members: { - addData: async function(datas) { - const newDatas = await this.self().respDataToTableData(datas); - this.setData(newDatas); - } + columnModel.addListener("orderChanged", e => { + // Save new order into preferences + if (e.getData()) { + const { fromOverXPos, toOverXPos } = e.getData() + // Edit current order + this.__columnOrder = this.__columnOrder.toSpliced(toOverXPos, 0, this.__columnOrder.splice(fromOverXPos, 1)[0]) + // Save order + osparc.Preferences.getInstance().setBillingCenterUsageColumnOrder(this.__columnOrder) + } + }, this) + + columnModel.setColumnWidth(0, 130) + columnModel.setColumnWidth(1, 130) + columnModel.setColumnWidth(3, 125) + columnModel.setColumnWidth(4, 70) + columnModel.setColumnWidth(5, 70) + columnModel.setColumnWidth(6, 56) + columnModel.setColumnWidth(7, 130) } -}); +}) diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTableModel.js b/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTableModel.js new file mode 100644 index 00000000000..2b8dffa4471 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/UsageTableModel.js @@ -0,0 +1,189 @@ +/* + * oSPARC - The SIMCORE frontend - https://osparc.io + * Copyright: 2024 IT'IS Foundation - https://itis.swiss + * License: MIT - https://opensource.org/licenses/MIT + * Authors: Ignacio Pascual (ignapas) + */ +const SERVER_MAX_LIMIT = 49 +const COLUMN_ID_TO_DB_COLUMN_MAP = { + 0: "project_name", + 1: "node_name", + 2: "service_key", + 3: "started_at", + 5: "service_run_status", + 6: "credit_cost", + 7: "user_email" +} + +qx.Class.define("osparc.desktop.credits.UsageTableModel", { + extend: qx.ui.table.model.Remote, + + construct(walletId, filters) { + this.base(arguments) + this.setColumns([ + osparc.product.Utils.getStudyAlias({firstUpperCase: true}), + qx.locale.Manager.tr("Node"), + qx.locale.Manager.tr("Service"), + qx.locale.Manager.tr("Start"), + qx.locale.Manager.tr("Duration"), + qx.locale.Manager.tr("Status"), + qx.locale.Manager.tr("Credits"), + qx.locale.Manager.tr("User") + ], [ + "project", + "node", + "service", + "start", + "duration", + "status", + "cost", + "user" + ]) + this.setWalletId(walletId) + if (filters) { + this.setFilters(filters) + } + this.setSortColumnIndexWithoutSortingData(3) + this.setSortAscendingWithoutSortingData(false) + this.setColumnSortable(4, false) + }, + + properties: { + walletId: { + check: "Number", + nullable: true + }, + filters: { + check: "Object", + init: null + }, + isFetching: { + check: "Boolean", + init: false, + event: "changeFetching" + }, + orderBy: { + check: "Object", + init: { + field: "started_at", + direction: "desc" + } + } + }, + + members: { + // overrriden + sortByColumn(columnIndex, ascending) { + this.setOrderBy({ + field: COLUMN_ID_TO_DB_COLUMN_MAP[columnIndex], + direction: ascending ? "asc" : "desc" + }) + this.base(arguments, columnIndex, ascending) + }, + // overridden + _loadRowCount() { + const endpoint = this.getWalletId() == null ? "get" : "getWithWallet" + osparc.data.Resources.fetch("resourceUsage", endpoint, { + url: { + walletId: this.getWalletId(), + limit: 1, + offset: 0, + filters: this.getFilters() ? + JSON.stringify({ + "started_at": this.getFilters() + }) : + null, + orderBy: JSON.stringify(this.getOrderBy()) + } + }, undefined, { + resolveWResponse: true + }) + .then(resp => { + this._onRowCountLoaded(resp["_meta"].total) + }) + .catch(() => { + this._onRowCountLoaded(null) + }) + }, + // overridden + _loadRowData(firstRow, qxLastRow) { + this.setIsFetching(true) + // Please Qloocloox don't ask for more rows than there are + const lastRow = Math.min(qxLastRow, this._rowCount - 1) + // Returns a request promise with given offset and limit + const getFetchPromise = (offset, limit=SERVER_MAX_LIMIT) => { + const endpoint = this.getWalletId() == null ? "get" : "getWithWallet" + return osparc.data.Resources.fetch("resourceUsage", endpoint, { + url: { + walletId: this.getWalletId(), + limit, + offset, + filters: this.getFilters() ? + JSON.stringify({ + "started_at": this.getFilters() + }) : + null, + orderBy: JSON.stringify(this.getOrderBy()) + } + }) + .then(rawData => { + const data = [] + rawData.forEach(rawRow => { + let service = "" + if (rawRow["service_key"]) { + const serviceName = rawRow["service_key"].split("/").pop() + service = `${serviceName}:${rawRow["service_version"]}` + } + let start = "" + let duration = "" + if (rawRow["started_at"]) { + start = osparc.utils.Utils.formatDateAndTime(new Date(rawRow["started_at"])) + if (rawRow["stopped_at"]) { + duration = osparc.utils.Utils.formatMilliSeconds(new Date(rawRow["stopped_at"]) - new Date(rawRow["started_at"])) + } + } + data.push({ + project: rawRow["project_name"] || rawRow["project_id"], + node: rawRow["node_name"] || rawRow["node_id"], + service, + start, + duration, + status: qx.lang.String.firstUp(rawRow["service_run_status"].toLowerCase()), + cost: rawRow["credit_cost"] ? rawRow["credit_cost"].toFixed(2) : "", + user: rawRow["user_email"] + }) + }) + return data + }) + } + // Divides the model row request into several server requests to comply with the number of rows server limit + const reqLimit = lastRow - firstRow + 1 // Number of requested rows + const nRequests = Math.ceil(reqLimit / SERVER_MAX_LIMIT) + if (nRequests > 1) { + let requests = [] + for (let i=firstRow; i <= lastRow; i += SERVER_MAX_LIMIT) { + requests.push(getFetchPromise(i, i > lastRow - SERVER_MAX_LIMIT + 1 ? reqLimit % SERVER_MAX_LIMIT : SERVER_MAX_LIMIT)) + } + Promise.all(requests) + .then(responses => { + this._onRowDataLoaded(responses.flat()) + }) + .catch(err => { + console.error(err) + this._onRowDataLoaded(null) + }) + .finally(() => this.setIsFetching(false)) + } else { + getFetchPromise(firstRow, reqLimit) + .then(data => { + this._onRowDataLoaded(data) + }) + .catch(err => { + console.error(err) + this._onRowDataLoaded(null) + }) + .finally(() => this.setIsFetching(false)) + } + } + } +}) diff --git a/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletListItem.js b/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletListItem.js index 33de815740c..1c8ad029cb8 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletListItem.js +++ b/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletListItem.js @@ -154,9 +154,17 @@ qx.Class.define("osparc.desktop.wallets.WalletListItem", { }); this.__autorechargeBtn.addListener("execute", () => { const autorecharge = new osparc.desktop.credits.AutoRecharge(this.getKey()); - const win = osparc.ui.window.Window.popUpInWindow(autorecharge, "Autorecharge", 400, 550); + const win = osparc.ui.window.Window.popUpInWindow(autorecharge, "Auto-recharge", 400, 550).set({ + resizable: false, + movable: false + }); autorecharge.addListener("close", () => win.close()); - // Revert default execute action (toggle the buttons's vale) + autorecharge.addListener("addNewPaymentMethod", () => { + win.close() + const newBillingCenter = osparc.desktop.credits.BillingCenterWindow.openWindow() + newBillingCenter.openPaymentMethods() + }) + // Revert default execute action (toggle the buttons's value) this.__autorechargeBtn.toggleValue(); }); this.bind("autoRecharge", this.__autorechargeBtn, "value", { diff --git a/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletsList.js b/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletsList.js index 8e72e5329f6..d73ef79a187 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletsList.js +++ b/services/static-webserver/client/source/class/osparc/desktop/wallets/WalletsList.js @@ -26,7 +26,7 @@ qx.Class.define("osparc.desktop.wallets.WalletsList", { this.__addHeader("Personal") this.__personalWalletsModel = this.__addWalletsList() - this.__addHeader("Shared with me") + this.__sharedHeader = this.__addHeader("Shared with me") this.__sharedWalletsModel = this.__addWalletsList({ flex: 1 }) this.loadWallets(); @@ -132,6 +132,11 @@ qx.Class.define("osparc.desktop.wallets.WalletsList", { } }); this.setWalletsLoaded(true); + if (this.__sharedWalletsModel.getLength() === 0) { + this.__sharedHeader.exclude() + } else { + this.__sharedHeader.show() + } }, __openEditWallet: function(walletId) { @@ -212,6 +217,7 @@ qx.Class.define("osparc.desktop.wallets.WalletsList", { }); header.add(selectColumn) this._add(header); + return header } } }); diff --git a/services/static-webserver/client/source/class/osparc/form/AppMotionSelect.js b/services/static-webserver/client/source/class/osparc/form/AppMotionSelect.js index 10da294e3dd..b8c0fc1a605 100644 --- a/services/static-webserver/client/source/class/osparc/form/AppMotionSelect.js +++ b/services/static-webserver/client/source/class/osparc/form/AppMotionSelect.js @@ -10,8 +10,7 @@ qx.Class.define("osparc.form.AppMotionSelect", { construct: function() { this.base(arguments); this.set({ - appearance: "appmotion-buy-credits-select", - decorator: "appmotion-buy-credits-input" + appearance: "appmotion-buy-credits-select" }); } }); diff --git a/services/static-webserver/client/source/class/osparc/theme/Appearance.js b/services/static-webserver/client/source/class/osparc/theme/Appearance.js index d722882a98f..bb2b1e84f51 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Appearance.js +++ b/services/static-webserver/client/source/class/osparc/theme/Appearance.js @@ -1180,9 +1180,10 @@ qx.Theme.define("osparc.theme.Appearance", { "appmotion-buy-credits-input": { include: "textfield", style: state => ({ - backgroundColor: state.readonly ? "transparent" : "background-main-1", + backgroundColor: state.disabled ? "transparent" : "background-main-1", padding: [10, 15], - font: "text-18" + font: "text-18", + decorator: "appmotion-buy-credits-input" }) }, @@ -1190,9 +1191,10 @@ qx.Theme.define("osparc.theme.Appearance", { include: "selectbox", alias: "selectbox", style: state => ({ - backgroundColor: "background-main-1", + backgroundColor: state.disabled ? "transparent" : "background-main-1", padding: [10, 15], - font: "text-14" + font: "text-14", + decorator: "appmotion-buy-credits-input" }) }, @@ -1202,6 +1204,27 @@ qx.Theme.define("osparc.theme.Appearance", { style: state => ({ backgroundColor: "background-main-1" }) + }, + + "appmotion-buy-credits-spinner": { + include: "spinner", + alias: "spinner" + }, + + "appmotion-buy-credits-spinner/textfield": { + include: "appmotion-buy-credits-input", + alias: "appmotion-buy-credits-input", + style: state => ({ + font: "text-14" + }) + }, + + "appmotion-buy-credits-checkbox": { + include: "checkbox", + alias: "checkbox", + style: state => ({ + icon: state.checked ? "@MaterialIcons/check_box/20" : "@MaterialIcons/check_box_outline_blank/20" + }) } } }); diff --git a/services/static-webserver/client/source/class/osparc/theme/Decoration.js b/services/static-webserver/client/source/class/osparc/theme/Decoration.js index 800d32a6b2b..3848cb7f2e0 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Decoration.js +++ b/services/static-webserver/client/source/class/osparc/theme/Decoration.js @@ -230,6 +230,12 @@ qx.Theme.define("osparc.theme.Decoration", { } }, + "no-border-2": { + style: { + width: 0 + } + }, + "border-status": { decorator: qx.ui.decoration.MSingleBorder, style: { diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 802373fce74..e3b77eaeec4 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -3152,8 +3152,8 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.resource_usage.ServiceRunGet__' - /v0/services/-/resource-usages:export: - post: + /v0/services/-/usage-report: + get: tags: - usage summary: Redirects to download CSV link. CSV obtains finished and currently diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py b/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py index b52321cc3bd..c72f69e0ec9 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py @@ -56,6 +56,6 @@ async def get_wallet_info( ) if wallet.available_credits <= ZERO_CREDITS: raise WalletNotEnoughCreditsError( - reason=f"Wallet {wallet.wallet_id} credit balance {wallet.available_credits}" + reason=f"Wallet '{wallet.name}' has {wallet.available_credits} credits." ) return WalletInfo(wallet_id=project_wallet_id, wallet_name=wallet.name) diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index 6f84ec83c4f..62a0a6b209b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -481,7 +481,7 @@ async def _start_dynamic_service( ) if wallet.available_credits <= ZERO_CREDITS: raise WalletNotEnoughCreditsError( - reason=f"Wallet {wallet.wallet_id} credit balance {wallet.available_credits}" + reason=f"Wallet '{wallet.name}' has {wallet.available_credits} credits." ) wallet_info = WalletInfo( wallet_id=project_wallet_id, wallet_name=wallet.name diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py index 1eb490fafad..a605fe1f853 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py @@ -167,9 +167,7 @@ async def list_resource_usage_services(request: web.Request): ) -@routes.post( - f"/{VTAG}/services/-/resource-usages:export", name="export_resource_usage_services" -) +@routes.get(f"/{VTAG}/services/-/usage-report", name="export_resource_usage_services") @login_required @permission_required("resource-usage.read") @_handle_resource_usage_exceptions diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py index 70383358f00..23ec71bad18 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_models.py @@ -95,6 +95,11 @@ class TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference( value: int = 5 # in gigabytes +class BillingCenterUsageColumnOrderFrontendUserPreference(FrontendUserPreference): + preference_identifier: PreferenceIdentifier = "billingCenterUsageColumnOrder" + value: list[int] | None = None + + ALL_FRONTEND_PREFERENCES: list[type[FrontendUserPreference]] = [ ConfirmationBackToDashboardFrontendUserPreference, ConfirmationDeleteStudyFrontendUserPreference, @@ -113,6 +118,7 @@ class TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference( JobConcurrencyLimitFrontendUserPreference, AllowMetricsCollectionFrontendUserPreference, TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference, + BillingCenterUsageColumnOrderFrontendUserPreference, ] _PREFERENCE_NAME_TO_IDENTIFIER_MAPPING: dict[PreferenceName, PreferenceIdentifier] = { diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py index 1511fae2291..53ff02997e5 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py @@ -72,7 +72,7 @@ async def test_export_service_usage_redirection( expected: type[web.HTTPException], ): url = client.app.router["export_resource_usage_services"].url_for() - resp = await client.post(f"{url}") + resp = await client.get(f"{url}") assert resp.status == expected.status_code if resp.status == web.HTTPOk: @@ -102,7 +102,7 @@ async def test_list_service_usage( order_by=json.dumps(_order_by), ) ) - resp = await client.post(f"{url}") + resp = await client.get(f"{url}") # checks is a redirection assert len(resp.history) == 1 diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py index 488d6f9cf36..f52cc497e7c 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py @@ -1,6 +1,7 @@ # pylint: disable=inconsistent-return-statements # pylint: disable=redefined-outer-name # pylint: disable=unused-argument +# pylint: disable=too-many-return-statements from collections.abc import AsyncIterator from typing import Any @@ -12,6 +13,7 @@ from faker import Faker from models_library.api_schemas_webserver.users_preferences import Preference from models_library.products import ProductName +from models_library.user_preferences import FrontendUserPreference from models_library.users import UserID from pydantic import BaseModel from pydantic.fields import ModelField @@ -22,11 +24,14 @@ ) from simcore_postgres_database.models.users import UserStatus from simcore_service_webserver.users._preferences_api import ( - ALL_FRONTEND_PREFERENCES, _get_frontend_user_preferences, get_frontend_user_preferences_aggregation, set_frontend_user_preference, ) +from simcore_service_webserver.users._preferences_models import ( + ALL_FRONTEND_PREFERENCES, + BillingCenterUsageColumnOrderFrontendUserPreference, +) @pytest.fixture @@ -72,7 +77,9 @@ def _get_default_field_value(model_class: type[BaseModel]) -> Any: ) -def _get_non_default_value(model_class: type[BaseModel]) -> Any: +def _get_non_default_value( + model_class: type[FrontendUserPreference], +) -> Any: """given a default value transforms into something that is different""" model_field = _get_model_field(model_class, "value") @@ -85,9 +92,15 @@ def _get_non_default_value(model_class: type[BaseModel]) -> Any: return {**value, "non_default_key": "non_default_value"} if isinstance(value, list): return [*value, "non_default_value"] - if isinstance(value, (int, str)): + if isinstance(value, int | str): return value + if value is None: + if ( + model_class.get_preference_name() + == BillingCenterUsageColumnOrderFrontendUserPreference.get_preference_name() + ): + return None if value_type == int: return 0 if value_type == str: