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
This commit is contained in:
Nariman Jelveh
2025-10-15 12:12:45 -07:00
committed by GitHub
parent b4cafaa5bb
commit ed5d629e02
3 changed files with 298 additions and 154 deletions

View File

@@ -25,172 +25,130 @@ export default {
html: () => {
return `
<h1>${i18n('usage')}</h1>
<div class="driver-usage">
<h3 style="margin-bottom: 5px; font-size: 14px;">${i18n('storage_usage')}</h3>
<div style="font-size: 13px; margin-bottom: 3px;">
<span id="storage-used"></span>
<span> used of </span>
<span id="storage-capacity"></span>
<span id="storage-puter-used-w" style="display:none;">&nbsp;(<span id="storage-puter-used"></span> ${i18n('storage_puter_used')})</span>
<div class="driver-usage" style="margin-top: 30px;">
<div class="driver-usage-header">
<h3 style="margin:0; font-size: 14px; flex-grow: 1;">${i18n('storage_usage')}</h3>
<div style="font-size: 13px; margin-bottom: 3px;">
<span id="storage-used"></span>
<span> used of </span>
<span id="storage-capacity"></span>
<span id="storage-puter-used-w" style="display:none;">&nbsp;(<span id="storage-puter-used"></span> ${i18n('storage_puter_used')})</span>
</div>
</div>
<div id="storage-bar-wrapper">
<span id="storage-used-percent"></span>
<div id="storage-bar"></div>
<div id="storage-bar-host"></div>
</div>
<div class="driver-usage-container">
<div class="driver-usage-header">
<h3 style="margin:0; font-size: 14px; flex-grow: 1;">${i18n('total_usage')}</h3>
<div style="font-size: 13px; margin-bottom: 3px;">
<span id="total-usage"></span>
<span> used of </span>
<span id="total-capacity"></span>
</div>
</div>
<div class="usage-progbar-wrapper">
<div class="usage-progbar" style="width: 0;">
<span class="usage-progbar-percent"></span>
</div>
</div>
<div class="driver-usage-details" style="margin-top: 5px; font-size: 13px; cursor: pointer;">
<div class="caret"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-caret-right-fill" viewBox="0 0 16 16"><path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/></svg></div>
<span class="driver-usage-details-text disable-user-select">View usage details</span>
</div>
<div class="driver-usage-details-content hide-scrollbar" style="display: none;">
</div>
</div>
</div>`;
},
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 += `
<div
class="driver-usage"
style="margin-bottom: 10px;"
data-id="${sanitize_id(entry.id)}"
>
<h3 style="margin-bottom: 5px; font-size: 14px;">${html_encode(name)}:</h3>
<span style="font-size: 13px; margin-bottom: 3px;">${i18n('used_of', {
...entry,
used: window.format_credits(entry.used),
available: window.format_credits(entry.available),
})}</span>
<div class="usage-progbar-wrapper" style="width: 100%;">
<div class="usage-progbar" style="width: ${Number(entry.usage_percentage)}%;"><span class="usage-progbar-percent">${Number(entry.usage_percentage)}%</span></div>
</div>
</div>
`;
});
const divContent = $el_window.find('.settings-content[data-settings="usage"]');
// Append driver usage bars to the container
divContent.append(`<div class="driver-usage-container">${h}</div>`);
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 = '<table class="driver-usage-details-content-table">';
h += `<thead>
<tr>
<th>Resource</th>
<th>Units</th>
<th>Cost</th>
</tr>
</thead>`;
h += `<tbody>`;
for(let key in res.usage){
// value must be object
if(typeof res.usage[key] !== 'object')
continue;
h += `
<tr>
<td>${key}</td>
<td>${window.format_credits(res.usage[key].units)}</td>
<td>${window.number_format(res.usage[key].cost / 100_000_000, { decimals: 2, prefix: '$' })}</td>
</tr>`;
}
h += `</tbody>`;
h += '</table>';
$('.driver-usage-details-content').html(h);
});
});

View File

@@ -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;

View File

@@ -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