mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-15 02:27:35 +00:00
Add Usage tab to dashboard with sortable usage table
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Sort state for the usage table
|
||||
let usageTableSortState = {
|
||||
column: 'cost', // default sort by cost
|
||||
direction: 'desc' // default descending (highest cost first)
|
||||
};
|
||||
let usageTableData = []; // Store raw data for sorting
|
||||
|
||||
const TabUsage = {
|
||||
id: 'usage',
|
||||
label: 'Usage',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-speedometer2" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/> <path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/> </svg>`,
|
||||
html: () => {
|
||||
return `
|
||||
<h1>${i18n('usage')}<button class="update-usage-details" style="float:right;"><svg class="update-usage-details-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/> <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/> </svg></button></h1>
|
||||
<div class="driver-usage">
|
||||
<div class="driver-usage-header">
|
||||
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Storage')}</h3>
|
||||
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
|
||||
<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" style="margin-top: 30px;">
|
||||
<div class="driver-usage-header">
|
||||
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Resources')}</h3>
|
||||
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
|
||||
<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) => {
|
||||
update_usage_details($el_window);
|
||||
$($el_window).find('.update-usage-details').on('click', function () {
|
||||
update_usage_details($el_window);
|
||||
});
|
||||
|
||||
// Scoped click handler for usage details toggle
|
||||
$($el_window).on('click', '.driver-usage-details', function () {
|
||||
const $container = $(this).closest('.driver-usage');
|
||||
$container.find('.driver-usage-details-content').toggleClass('active');
|
||||
$(this).toggleClass('active');
|
||||
|
||||
// change the text of the driver-usage-details-text depending on the class
|
||||
if ( $(this).hasClass('active') ) {
|
||||
$(this).find('.driver-usage-details-text').text('Hide usage details');
|
||||
} else {
|
||||
$(this).find('.driver-usage-details-text').text('View usage details');
|
||||
}
|
||||
});
|
||||
|
||||
// Click handler for sortable table headers
|
||||
$($el_window).on('click', '.driver-usage-details-content-table th[data-sort]', function () {
|
||||
const column = $(this).data('sort');
|
||||
|
||||
// Toggle direction if same column, otherwise default to descending
|
||||
if ( usageTableSortState.column === column ) {
|
||||
usageTableSortState.direction = usageTableSortState.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
usageTableSortState.column = column;
|
||||
usageTableSortState.direction = 'desc';
|
||||
}
|
||||
|
||||
renderUsageTable();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function getSortIcon(column) {
|
||||
const isActive = usageTableSortState.column === column;
|
||||
const direction = usageTableSortState.direction;
|
||||
|
||||
if ( !isActive ) {
|
||||
// Neutral sort icon (both arrows, dimmed)
|
||||
return `<span class="sort-icon sort-icon-neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M3.5 3.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 12.293V3.5zm4 .5a.5.5 0 0 1 0-1h1a.5.5 0 0 1 0 1h-1zm0 3a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1h-3zm0 3a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1h-5zm0 3a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1h-7z"/>
|
||||
</svg>
|
||||
</span>`;
|
||||
} else if ( direction === 'asc' ) {
|
||||
// Ascending icon
|
||||
return `<span class="sort-icon sort-icon-asc">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M3.5 12.5a.5.5 0 0 1-1 0V3.707L1.354 4.854a.5.5 0 1 1-.708-.708l2-1.999.007-.007a.498.498 0 0 1 .7.006l2 2a.5.5 0 1 1-.707.708L3.5 3.707V12.5zm3.5-9a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zM7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1z"/>
|
||||
</svg>
|
||||
</span>`;
|
||||
} else {
|
||||
// Descending icon
|
||||
return `<span class="sort-icon sort-icon-desc">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293V2.5zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zM7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1z"/>
|
||||
</svg>
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsageTable() {
|
||||
// Sort the data
|
||||
const sortedData = [...usageTableData].sort((a, b) => {
|
||||
let aVal, bVal;
|
||||
|
||||
switch ( usageTableSortState.column ) {
|
||||
case 'resource':
|
||||
aVal = a.resource.toLowerCase();
|
||||
bVal = b.resource.toLowerCase();
|
||||
break;
|
||||
case 'cost':
|
||||
default:
|
||||
aVal = a.rawCost;
|
||||
bVal = b.rawCost;
|
||||
break;
|
||||
}
|
||||
|
||||
if ( aVal < bVal ) return usageTableSortState.direction === 'asc' ? -1 : 1;
|
||||
if ( aVal > bVal ) return usageTableSortState.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Build the table
|
||||
let h = '<table class="driver-usage-details-content-table">';
|
||||
|
||||
h += `<thead>
|
||||
<tr>
|
||||
<th data-sort="resource" class="sortable-th">Resource ${getSortIcon('resource')}</th>
|
||||
<th>Units</th>
|
||||
<th data-sort="cost" class="sortable-th">Cost ${getSortIcon('cost')}</th>
|
||||
</tr>
|
||||
</thead>`;
|
||||
|
||||
h += '<tbody>';
|
||||
for ( const row of sortedData ) {
|
||||
h += `
|
||||
<tr>
|
||||
<td>${row.resource}</td>
|
||||
<td>${row.formattedUnits}</td>
|
||||
<td>${row.formattedCost}</td>
|
||||
</tr>`;
|
||||
}
|
||||
h += '</tbody>';
|
||||
h += '</table>';
|
||||
|
||||
$('.driver-usage-details-content').html(h);
|
||||
}
|
||||
|
||||
async function update_usage_details ($el_window) {
|
||||
// Add spinning animation and record start time
|
||||
const startTime = Date.now();
|
||||
$($el_window).find('.update-usage-details-icon').css('animation', 'spin 1s linear infinite');
|
||||
|
||||
const monthlyUsagePromise = 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);
|
||||
|
||||
$('#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 }%`);
|
||||
|
||||
// Store raw data for sorting
|
||||
usageTableData = [];
|
||||
for ( let key in res.usage ) {
|
||||
// value must be object
|
||||
if ( typeof res.usage[key] !== 'object' ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawUnits = res.usage[key].units;
|
||||
const rawCost = res.usage[key].cost;
|
||||
|
||||
// Format units for display
|
||||
let formattedUnits;
|
||||
if ( key.startsWith('filesystem:') && key.endsWith(':bytes') ) {
|
||||
formattedUnits = window.byte_format(rawUnits);
|
||||
} else {
|
||||
formattedUnits = window.number_format(rawUnits, { decimals: 0, thousandSeparator: ',' });
|
||||
}
|
||||
|
||||
usageTableData.push({
|
||||
resource: key,
|
||||
rawUnits: rawUnits,
|
||||
formattedUnits: formattedUnits,
|
||||
rawCost: rawCost,
|
||||
formattedCost: window.number_format(rawCost / 100_000_000, { decimals: 2, prefix: '$' })
|
||||
});
|
||||
}
|
||||
|
||||
renderUsageTable();
|
||||
});
|
||||
|
||||
const spacePromise = 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 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);
|
||||
}
|
||||
|
||||
$('#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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both promises to complete
|
||||
await Promise.all([monthlyUsagePromise, spacePromise]);
|
||||
|
||||
// Ensure spinning continues for at least 1 second
|
||||
const elapsed = Date.now() - startTime;
|
||||
const minDuration = 1000; // 1 second
|
||||
if ( elapsed < minDuration ) {
|
||||
await new Promise(resolve => setTimeout(resolve, minDuration - elapsed));
|
||||
}
|
||||
|
||||
// Remove spinning animation
|
||||
$($el_window).find('.update-usage-details-icon').css('animation', '');
|
||||
}
|
||||
|
||||
export default TabUsage;
|
||||
@@ -28,11 +28,13 @@ import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
// Import tab modules
|
||||
import TabFiles from './TabFiles.js';
|
||||
import TabApps from './TabApps.js';
|
||||
import TabUsage from './TabUsage.js';
|
||||
|
||||
// Registry of all available tabs
|
||||
const tabs = [
|
||||
TabFiles,
|
||||
TabApps,
|
||||
TabUsage,
|
||||
];
|
||||
|
||||
async function UIDashboard (options) {
|
||||
@@ -51,14 +53,20 @@ async function UIDashboard (options) {
|
||||
h += '<div class="dashboard-sidebar">';
|
||||
// Navigation items container
|
||||
h += '<div class="dashboard-sidebar-nav">';
|
||||
for ( let i = 0; i < tabs.length; i++ ) {
|
||||
const tab = tabs[i];
|
||||
const isActive = i === 0 ? ' active' : '';
|
||||
h += `<div class="dashboard-sidebar-item${isActive}" data-section="${tab.id}">`;
|
||||
h += tab.icon;
|
||||
h += tab.label;
|
||||
h += `<div class="dashboard-sidebar-item active" data-section="${TabFiles.id}">`;
|
||||
h += TabFiles.icon;
|
||||
h += TabFiles.label;
|
||||
h += '</div>';
|
||||
|
||||
h += `<div class="dashboard-sidebar-item" data-section="${TabApps.id}">`;
|
||||
h += TabApps.icon;
|
||||
h += TabApps.label;
|
||||
h += '</div>';
|
||||
|
||||
h += `<div class="dashboard-sidebar-item" data-section="${TabUsage.id}">`;
|
||||
h += TabUsage.icon;
|
||||
h += TabUsage.label;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
// User options button at bottom
|
||||
|
||||
@@ -68,21 +68,23 @@ export default {
|
||||
$($el_window).find('.update-usage-details').on('click', function () {
|
||||
update_usage_details($el_window);
|
||||
});
|
||||
|
||||
// Scoped click handler for usage details toggle
|
||||
$($el_window).on('click', '.driver-usage-details', function () {
|
||||
const $container = $(this).closest('.driver-usage');
|
||||
$container.find('.driver-usage-details-content').toggleClass('active');
|
||||
$(this).toggleClass('active');
|
||||
|
||||
// change the text of the driver-usage-details-text depending on the class
|
||||
if ( $(this).hasClass('active') ) {
|
||||
$(this).find('.driver-usage-details-text').text('Hide usage details');
|
||||
} else {
|
||||
$(this).find('.driver-usage-details-text').text('View usage details');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
$(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');
|
||||
}
|
||||
});
|
||||
|
||||
async function update_usage_details ($el_window) {
|
||||
// Add spinning animation and record start time
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -4650,6 +4650,31 @@ fieldset[name=number-code] {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th.sortable-th {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th.sortable-th:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th .sort-icon {
|
||||
margin-left: 4px;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th .sort-icon-neutral {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th .sort-icon-asc,
|
||||
.driver-usage-details-content-table thead th .sort-icon-desc {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table td {
|
||||
padding: 7px 5px;
|
||||
border: 1px solid #e0e0e0;
|
||||
@@ -5573,6 +5598,11 @@ fieldset[name=number-code] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-section-usage {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Dashboard Apps */
|
||||
.dashboard-apps-container {
|
||||
margin-top: 8px;
|
||||
@@ -5693,6 +5723,12 @@ fieldset[name=number-code] {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
.dashboard-sidebar-separator {
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Responsive: tablet and below */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-sidebar-toggle {
|
||||
|
||||
Reference in New Issue
Block a user