From ed5d629e025ba3994d9f64ce893dfb32623495c8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 15 Oct 2025 12:12:45 -0700 Subject: [PATCH] Detailed usage report (#1752) * Implement detailed usage report table - Added `number_format` function for flexible number formatting with options for decimals, separators, and negative formatting. - Updated `UITabUsage.js` to include a toggle for viewing driver usage details, fetching and displaying usage data dynamically. - Improved CSS styles for driver usage details to improve layout and interactivity. * Update style.css * Refactor driver usage display in UITabUsage.js - Updated UITabUsage.js to improve the layout of driver usage information, including a new header structure for better organization. - Added CSS styles for the new header layout and adjusted existing styles for improved visual consistency and interactivity. * fix progress bar logic --- src/gui/src/UI/Settings/UITabUsage.js | 256 +++++++++++--------------- src/gui/src/css/style.css | 94 +++++++++- src/gui/src/helpers.js | 102 ++++++++++ 3 files changed, 298 insertions(+), 154 deletions(-) diff --git a/src/gui/src/UI/Settings/UITabUsage.js b/src/gui/src/UI/Settings/UITabUsage.js index d19363b71..93014efc8 100644 --- a/src/gui/src/UI/Settings/UITabUsage.js +++ b/src/gui/src/UI/Settings/UITabUsage.js @@ -25,172 +25,130 @@ export default { html: () => { return `

${i18n('usage')}

-
-

${i18n('storage_usage')}

-
- - used of - - +
+
+

${i18n('storage_usage')}

+
+ + used of + + +
+
+
+

${i18n('total_usage')}

+
+ + used of + +
+
+
+
+ +
+
+
+
+ View usage details +
+ +
`; }, init: ($el_window) => { - const sanitize_id = id => (''+id).replace(/[^A-Za-z0-9-]/g, ''); - $.ajax({ - url: window.api_origin + "/drivers/usage", - type: 'GET', - async: true, - contentType: "application/json", - headers: { - "Authorization": "Bearer " + window.auth_token - }, - statusCode: { - 401: function () { - window.logout(); - }, - }, - success: function (res) { - let h = ''; // Initialize HTML string for driver usage bars + puter.auth.getMonthlyUsage().then(res => { + let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance; + let remaining = res.allowanceInfo?.remaining; + let totalUsage = monthlyAllowance - remaining; + let totalUsagePercentage = (totalUsage / monthlyAllowance * 100).toFixed(0); - // Usages provided by arbitrary services - res.usages.forEach(entry => { - if ( ! entry.usage_percentage ) { - entry.usage_percentage = (entry.used / entry.available * 100).toFixed(0); - } - - // Skip the 'ai-chat (complete)' entry since we've made it infinite for now - if(entry.name === 'ai-chat (complete)') - return; - - if(entry.name.startsWith('es:subdomain')) - return; - - if(entry.name.startsWith('es:app')) - return; - - let name = entry.name; - - if(name === 'convert-api (convert)') - name = `File Conversions`; - - h += ` -
-

${html_encode(name)}:

- ${i18n('used_of', { - ...entry, - used: window.format_credits(entry.used), - available: window.format_credits(entry.available), - })} -
-
${Number(entry.usage_percentage)}%
-
-
- `; - }); - - const divContent = $el_window.find('.settings-content[data-settings="usage"]'); - - // Append driver usage bars to the container - divContent.append(`
${h}
`); - - const update_usage = event => { - if ( ! event.usage_percentage ) { - event.usage_percentage = (event.used / event.available * 100).toFixed(0); - } - const el_divContent = divContent[0]; - el_divContent - .querySelector(`[data-id=${sanitize_id(event.id)}] .usage-progbar`) - .style.width = '' + Number(event.usage_percentage) + '%'; - el_divContent - .querySelector(`[data-id=${sanitize_id(event.id)}] .usage-progbar span`) - .innerText = '' + Number(event.usage_percentage) + '%'; - const used_of_str = i18n('used_of', { - ...event, - used: window.format_credits(event.used), - available: window.format_credits(event.available), - }); - el_divContent - .querySelector(`[data-id=${sanitize_id(event.id)}] > span`) - .innerText = used_of_str; - }; - - - const interval = setInterval(async () => { - const resp = await fetch(`${window.api_origin}/drivers/usage`, { - headers: { - "Authorization": "Bearer " + window.auth_token - }, - }) - const usages = (await resp.json()).usages; - for ( const usage of usages ) { - if ( ! usage.id ) continue; - update_usage(usage); - } - }, 2000); - - divContent.on('remove', () => { - socket.off('usage.update', update_usage); - clearInterval(interval); - }); - socket.on('usage.update', update_usage); - } + $('#total-usage').html(window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })); + $('#total-capacity').html(window.number_format(monthlyAllowance / 100_000_000, { decimals: 2, prefix: '$' })); + $('.usage-progbar-percent').html(totalUsagePercentage + '%'); + $('.usage-progbar').css('width', totalUsagePercentage + '%'); + }); - // df - $.ajax({ - url: window.api_origin + "/df", - type: 'GET', - async: true, - contentType: "application/json", - headers: { - "Authorization": "Bearer " + window.auth_token - }, - statusCode: { - 401: function () { - window.logout(); - }, - }, - success: function (res) { - let usage_percentage = (res.used / res.capacity * 100).toFixed(0); - usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; + puter.fs.space().then(res => { + let usage_percentage = (res.used / res.capacity * 100).toFixed(0); + usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; - let general_used = res.used; + let general_used = res.used; - let host_usage_percentage = 0; - if ( res.host_used ) { - $('#storage-puter-used').html(window.byte_format(res.used)); - $('#storage-puter-used-w').show(); + let host_usage_percentage = 0; + if ( res.host_used ) { + $('#storage-puter-used').html(window.byte_format(res.used)); + $('#storage-puter-used-w').show(); - general_used = res.host_used; - host_usage_percentage = ((res.host_used - res.used) / res.capacity * 100).toFixed(0); - } + general_used = res.host_used; + host_usage_percentage = ((res.host_used - res.used) / res.capacity * 100).toFixed(0); + } - $('#storage-used').html(window.byte_format(general_used)); - $('#storage-capacity').html(window.byte_format(res.capacity)); - $('#storage-used-percent').html( - usage_percentage + '%' + - (host_usage_percentage > 0 - ? ' / ' + host_usage_percentage + '%' : '') - ); - $('#storage-bar').css('width', usage_percentage + '%'); - $('#storage-bar-host').css('width', host_usage_percentage + '%'); - if (usage_percentage >= 100) { - $('#storage-bar').css({ - 'border-top-right-radius': '3px', - 'border-bottom-right-radius': '3px', - }); - } + $('#storage-used').html(window.byte_format(general_used)); + $('#storage-capacity').html(window.byte_format(res.capacity)); + $('#storage-used-percent').html( + usage_percentage + '%' + + (host_usage_percentage > 0 + ? ' / ' + host_usage_percentage + '%' : '') + ); + $('#storage-bar').css('width', usage_percentage + '%'); + $('#storage-bar-host').css('width', host_usage_percentage + '%'); + if (usage_percentage >= 100) { + $('#storage-bar').css({ + 'border-top-right-radius': '3px', + 'border-bottom-right-radius': '3px', + }); } }); }, }; + +$(document).on('click', '.driver-usage-details', function() { + $('.driver-usage-details-content').toggleClass('active'); + $('.driver-usage-details').toggleClass('active'); + + // change the text of the driver-usage-details-text depending on the class + if($('.driver-usage-details').hasClass('active')){ + $('.driver-usage-details-text').text('Hide usage details'); + }else{ + $('.driver-usage-details-text').text('View usage details'); + } + + puter.auth.getMonthlyUsage().then(res => { + let h = ''; + + h += ` + + + + + + `; + + h += ``; + for(let key in res.usage){ + // value must be object + if(typeof res.usage[key] !== 'object') + continue; + + h += ` + + + + + `; + } + h += ``; + h += '
ResourceUnitsCost
${key}${window.format_credits(res.usage[key].units)}${window.number_format(res.usage[key].cost / 100_000_000, { decimals: 2, prefix: '$' })}
'; + + $('.driver-usage-details-content').html(h); + }); +}); \ No newline at end of file diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index a09c6af5d..e6b55ff1a 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -4334,7 +4334,9 @@ fieldset[name=number-code] { } .settings-content.active { - display: block; + display: flex; + flex-direction: column; + height: 100%; } .settings-content .about-container { @@ -4451,8 +4453,13 @@ fieldset[name=number-code] { box-sizing: border-box; color: #3c4963; height: 85px; + display: flex; + flex-direction: column; } +.driver-usage-container .driver-usage{ + flex-grow: 1; +} .credits { padding: 0; border: 1px solid #bfbfbf; @@ -4486,12 +4493,18 @@ fieldset[name=number-code] { margin-bottom: 10px; } +.driver-usage-header{ + display: flex; + flex-direction: row; + margin-bottom: 5px; +} + #storage-bar-wrapper { width: 100%; height: 20px; - border: 1px solid #8a9096; + border: 1px solid #dddddd; border-radius: 3px; - background-color: #fff; + background-color: #fbfbfb; position: relative; display: flex; align-items: center; @@ -4526,9 +4539,9 @@ fieldset[name=number-code] { .usage-progbar-wrapper { width: 100%; height: 20px; - border: 1px solid #8a9096; + border: 1px solid #dddddd; border-radius: 3px; - background-color: #fff; + background-color: #fbfbfb; position: relative; display: flex; align-items: center; @@ -4552,7 +4565,78 @@ fieldset[name=number-code] { font-size: 13px; line-height: 20px; } +.driver-usage-container{ + flex-grow: 1; + display: flex; + flex-direction: column; + margin-top: 20px; +} +.driver-usage-details-text{ + cursor: pointer !important; +} +.driver-usage-details-content { + display: none; + margin-top: 10px; + border-radius: 4px; +} +.driver-usage-details-content.active { + display: block !important; + flex-grow: 1; + overflow-y: scroll; +} + +.driver-usage-details{ + display: inline-block; + cursor: pointer; + display: flex; + align-items: center; +} + +.driver-usage-details .caret { + transition: transform 0.1s ease-in-out; + margin-right: 3px; + display: inline-block; + width: 16px; + height: 16px; + opacity: 0.7; +} + +.driver-usage-details.active .caret { + transform: rotate(90deg); + margin-top: -2px; +} + +.driver-usage-details-content-table { + width: 100%; + border-collapse: collapse; +} + +.driver-usage-details-content-table thead { + background-color: #f0f0f0; +} + +.driver-usage-details-content-table thead th { + padding: 5px; + border: 1px solid #e0e0e0; + text-align: left; + font-size: 14px; + font-weight: 500; +} + +.driver-usage-details-content-table td { + padding: 5px; + border: 1px solid #e0e0e0; + max-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 14px; +} + +.driver-usage-details-content-table td:first-child { + width: 50%; +} .version { font-size: 9px; color: #343c4f; diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index fb2b46edd..9b95ca859 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -3064,6 +3064,108 @@ window.format_credits = (num) => { return window.format_with_units(num, { mulUnits }) }; +/** + * General-purpose number formatting function with support for decimal places, + * thousand separators, and various formatting options. + * + * @param {number} num - The number to format + * @param {Object} options - Formatting options + * @param {number} options.decimals - Number of decimal places (default: 0) + * @param {string} options.decimalSeparator - Decimal separator character (default: '.') + * @param {string} options.thousandSeparator - Thousand separator character (default: ',') + * @param {string} options.prefix - String to prepend (e.g., '$' for currency) + * @param {string} options.suffix - String to append (e.g., '%' for percentage) + * @param {boolean} options.stripInsignificantZeros - Remove trailing zeros after decimal (default: false) + * @param {string} options.negativeFormat - Format for negative numbers: 'sign' (default), 'parentheses', or 'accounting' + * @param {boolean} options.forceSign - Always show sign for positive numbers (default: false) + * + * @returns {string} Formatted number string + * + * @example + * number_format(1234.5) // "1,234" + * number_format(1234.5, { decimals: 2 }) // "1,234.50" + * number_format(1234.5678, { decimals: 2 }) // "1,234.57" + * number_format(1234567.89, { decimals: 2, prefix: '$' }) // "$1,234,567.89" + * number_format(0.5, { decimals: 1, suffix: '%' }) // "0.5%" + * number_format(-1234.5, { decimals: 2 }) // "-1,234.50" + * number_format(-1234.5, { decimals: 2, negativeFormat: 'parentheses' }) // "(1,234.50)" + * number_format(1234.5, { decimals: 2, thousandSeparator: ' ' }) // "1 234.50" + * number_format(1234.5, { decimals: 2, decimalSeparator: ',' }) // "1.234,50" + */ +window.number_format = (num, options = {}) => { + // Default options + const { + decimals = 0, + decimalSeparator = '.', + thousandSeparator = ',', + prefix = '', + suffix = '', + stripInsignificantZeros = false, + negativeFormat = 'sign', // 'sign', 'parentheses', 'accounting' + forceSign = false, + } = options; + + // Handle non-numeric values + if (num === null || num === undefined || isNaN(num)) { + return prefix + '0' + suffix; + } + + // Handle infinity + if (!isFinite(num)) { + return num > 0 ? prefix + '∞' + suffix : prefix + '-∞' + suffix; + } + + const isNegative = num < 0; + const absNum = Math.abs(num); + + // Round to specified decimal places + const multiplier = Math.pow(10, decimals); + const rounded = Math.round(absNum * multiplier) / multiplier; + + // Split into integer and decimal parts + let [intPart, decPart] = rounded.toFixed(decimals).split('.'); + + // Add thousand separators to integer part + if (thousandSeparator) { + intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator); + } + + // Build the number string + let numStr = intPart; + if (decimals > 0) { + // Handle stripInsignificantZeros + if (stripInsignificantZeros && decPart) { + decPart = decPart.replace(/0+$/, ''); + } + if (decPart && decPart.length > 0) { + numStr += decimalSeparator + decPart; + } else if (!stripInsignificantZeros) { + numStr += decimalSeparator + decPart; + } + } + + // Handle negative formatting + let sign = ''; + let wrapper = { start: '', end: '' }; + + if (isNegative) { + if (negativeFormat === 'parentheses') { + wrapper = { start: '(', end: ')' }; + } else if (negativeFormat === 'accounting') { + // Accounting format: negative in parentheses with red color context + wrapper = { start: '(', end: ')' }; + } else { + // Default: sign format + sign = '-'; + } + } else if (forceSign && num > 0) { + sign = '+'; + } + + // Assemble final string + return wrapper.start + sign + prefix + numStr + suffix + wrapper.end; +}; + /** * This function will call the provided action function in a try...catch * and handle the 'item_with_same_name_exists' error by re-calling the