mirror of
https://github.com/HeyPuter/puter.git
synced 2026-01-09 22:51:29 -06:00
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:
@@ -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;"> (<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;"> (<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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user