Implement #368 -- Add administrator-set storage quotas to individual stations. (#1122)

* Implement station quotas at DB level, switch to big-int math for checks.
* Update AzuraCore
* No longer try to auto-typecast fields (let the setters do that).
* Implement admin form, fix Flow error displays, implement checking/adding quota.
* Decrement from quota when a file is deleted.
* More robust handling of empty values.
This commit is contained in:
Buster "Silver Eagle" Neece 2019-01-28 14:47:19 -06:00 committed by GitHub
parent fa586d1048
commit c1f2017052
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 407 additions and 112 deletions

View File

@ -42,7 +42,8 @@
"symfony/validator": "^4.2",
"ramsey/uuid": "^3.8",
"wikimedia/composer-merge-plugin": "^1.4",
"zircote/swagger-php": "^3.0"
"zircote/swagger-php": "^3.0",
"brick/math": "^0.8.4"
},
"require-dev": {
"codeception/codeception": "^2.2",

53
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "739eb0ecaeab301f0950a7f5972094ba",
"content-hash": "3a668ffc79f88c7c87f6c1fc9b3850e3",
"packages": [
{
"name": "aws/aws-sdk-php",
@ -95,12 +95,12 @@
"source": {
"type": "git",
"url": "https://github.com/AzuraCast/azuracore.git",
"reference": "882e89f94e15b6e3e37c92382abded67ca700235"
"reference": "30c19fb89fced3511565136535dfd1ef3ee320c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/882e89f94e15b6e3e37c92382abded67ca700235",
"reference": "882e89f94e15b6e3e37c92382abded67ca700235",
"url": "https://api.github.com/repos/AzuraCast/azuracore/zipball/30c19fb89fced3511565136535dfd1ef3ee320c3",
"reference": "30c19fb89fced3511565136535dfd1ef3ee320c3",
"shasum": ""
},
"require": {
@ -145,7 +145,7 @@
}
],
"description": "A lightweight core application framework.",
"time": "2019-01-18T01:19:21+00:00"
"time": "2019-01-28T17:36:30+00:00"
},
{
"name": "azuracast/azuraforms",
@ -407,6 +407,49 @@
],
"time": "2018-01-13T09:47:09+00:00"
},
{
"name": "brick/math",
"version": "0.8.4",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "41eeaed92906856968a3d4cc1020a70647c9e97f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/41eeaed92906856968a3d4cc1020a70647c9e97f",
"reference": "41eeaed92906856968a3d4cc1020a70647c9e97f",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"php-coveralls/php-coveralls": "2.*",
"phpunit/phpunit": "7.*"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"brick",
"math"
],
"time": "2018-12-07T14:38:55+00:00"
},
{
"name": "cakephp/chronos",
"version": "1.2.3",

View File

@ -111,6 +111,14 @@ return [
]
],
'storage_quota' => [
'text',
[
'label' => __('Storage Quota'),
'description' => __('Set a maximum disk space that this station can use. Specify the size with unit, i.e. "8 GB". Units are measured in 1024 bytes. Leave blank to default to the available space on the disk.')
]
],
'radio_base_dir' => [
'text',
[

View File

@ -100,9 +100,14 @@ class BatchController extends FilesControllerAbstract
if ('dir' === $file_meta['type']) {
$fs->deleteDir($file);
} else {
$station->removeStorageUsed($file_meta['size']);
$fs->delete($file);
}
}
$this->em->persist($station);
$this->em->flush($station);
break;
case 'clear':

View File

@ -47,13 +47,6 @@ class FilesController extends FilesControllerAbstract
->setParameter('station_id', $station_id)
->setParameter('source', Entity\StationPlaylist::SOURCE_SONGS)
->getArrayResult();
// Show available file space in the station directory.
// TODO: This won't be applicable for stations that don't use local storage!
$media_dir = $station->getRadioMediaDir();
$space_free = disk_free_space($media_dir);
$space_total = disk_total_space($media_dir);
$space_used = $space_total - $space_free;
$files_count = $this->em->createQuery('SELECT COUNT(sm.id) FROM '.Entity\StationMedia::class.' sm
WHERE sm.station_id = :station_id')
@ -72,27 +65,14 @@ class FilesController extends FilesControllerAbstract
return $request->getView()->renderToResponse($response, 'stations/files/index', [
'playlists' => $playlists,
'custom_fields' => $custom_fields,
'space_free' => Utilities::bytes_to_text($space_free),
'space_used' => Utilities::bytes_to_text($space_used),
'space_total' => Utilities::bytes_to_text($space_total),
'space_percent' => round(($space_used / $space_total) * 100),
'space_used' => $station->getStorageUsed(),
'space_total' => $station->getStorageAvailable(),
'space_percent' => $station->getStorageUsePercentage(),
'files_count' => $files_count,
'csrf' => $request->getSession()->getCsrf()->generate($this->csrf_namespace),
'max_upload_size' => min(
$this->_asBytes(ini_get('post_max_size')),
$this->_asBytes(ini_get('upload_max_filesize'))
),
]);
}
protected function _asBytes($ini_v)
{
$ini_v = trim($ini_v);
$s = ['g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10];
return (int)$ini_v * ($s[strtolower(substr($ini_v, -1))] ?: 1);
}
public function renameAction(Request $request, Response $response, $station_id): ResponseInterface
{
$station = $request->getStation();
@ -220,6 +200,10 @@ class FilesController extends FilesControllerAbstract
$station = $request->getStation();
if ($station->isStorageFull()) {
throw new \App\Exception\OutOfSpace(__('This station is out of available storage space.'));
}
try {
$flow = new \App\Service\Flow($request, $response, $station->getRadioTempDir());
$flow_response = $flow->process();
@ -270,6 +254,7 @@ class FilesController extends FilesControllerAbstract
}
}
$station->addStorageUsed($flow_response['size']);
$this->em->flush();
return $response->withJson(['success' => true]);

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20190128035353 extends AbstractMigration
{
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station ADD storage_quota BIGINT DEFAULT NULL, ADD storage_used BIGINT DEFAULT NULL');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE station DROP storage_quota, DROP storage_used');
}
}

View File

@ -1,6 +1,8 @@
<?php
namespace App\Entity;
use App\Radio\Quota;
use Brick\Math\BigInteger;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@ -201,6 +203,18 @@ class Station
*/
protected $api_history_items = self::DEFAULT_API_HISTORY_ITEMS;
/**
* @ORM\Column(name="storage_quota", type="bigint", nullable=true)
* @var string|null
*/
protected $storage_quota;
/**
* @ORM\Column(name="storage_used", type="bigint", nullable=true)
* @var string|null
*/
protected $storage_used;
/**
* @ORM\OneToMany(targetEntity="SongHistory", mappedBy="station")
* @ORM\OrderBy({"timestamp" = "DESC"})
@ -296,7 +310,7 @@ class Station
/**
* @param null|string $name
*/
public function setName(?string $name = null)
public function setName(?string $name = null): void
{
$this->name = $this->_truncateString($name, 100);
@ -353,7 +367,7 @@ class Station
/**
* @param null|string $frontend_type
*/
public function setFrontendType(string $frontend_type = null)
public function setFrontendType(string $frontend_type = null): void
{
$this->frontend_type = $frontend_type;
}
@ -370,7 +384,7 @@ class Station
* @param $frontend_config
* @param bool $force_overwrite
*/
public function setFrontendConfig($frontend_config, $force_overwrite = false)
public function setFrontendConfig($frontend_config, $force_overwrite = false): void
{
$config = ($force_overwrite) ? [] : (array)$this->frontend_config;
foreach((array)$frontend_config as $cfg_key => $cfg_val) {
@ -389,7 +403,7 @@ class Station
*
* @param $default_config
*/
public function setFrontendConfigDefaults($default_config)
public function setFrontendConfigDefaults($default_config): void
{
$frontend_config = (array)$this->frontend_config;
@ -413,7 +427,7 @@ class Station
/**
* @param null|string $backend_type
*/
public function setBackendType(string $backend_type = null)
public function setBackendType(string $backend_type = null): void
{
$this->backend_type = $backend_type;
}
@ -430,7 +444,7 @@ class Station
* @param $backend_config
* @param bool $force_overwrite
*/
public function setBackendConfig($backend_config, $force_overwrite = false)
public function setBackendConfig($backend_config, $force_overwrite = false): void
{
$config = ($force_overwrite) ? [] : (array)$this->backend_config;
foreach((array)$backend_config as $cfg_key => $cfg_val) {
@ -466,7 +480,7 @@ class Station
/**
* Generate a random new adapter API key.
*/
public function generateAdapterApiKey()
public function generateAdapterApiKey(): void
{
$this->adapter_api_key = bin2hex(random_bytes(50));
}
@ -477,7 +491,7 @@ class Station
* @param $api_key
* @return bool
*/
public function validateAdapterApiKey($api_key)
public function validateAdapterApiKey($api_key): bool
{
return hash_equals($api_key, $this->adapter_api_key);
}
@ -493,7 +507,7 @@ class Station
/**
* @param null|string $description
*/
public function setDescription(string $description = null)
public function setDescription(string $description = null): void
{
$this->description = $description;
}
@ -509,7 +523,7 @@ class Station
/**
* @param null|string $url
*/
public function setUrl(string $url = null)
public function setUrl(string $url = null): void
{
$this->url = $this->_truncateString($url);
}
@ -541,7 +555,7 @@ class Station
/**
* @param $new_dir
*/
public function setRadioBaseDir($new_dir)
public function setRadioBaseDir($new_dir): void
{
$new_dir = $this->_truncateString(trim($new_dir));
@ -622,7 +636,7 @@ class Station
/**
* @param $new_dir
*/
public function setRadioMediaDir(?string $new_dir)
public function setRadioMediaDir(?string $new_dir): void
{
$new_dir = $this->_truncateString(trim($new_dir));
@ -651,7 +665,7 @@ class Station
/**
* @param Api\NowPlaying|null $nowplaying
*/
public function setNowplaying(Api\NowPlaying $nowplaying = null)
public function setNowplaying(Api\NowPlaying $nowplaying = null): void
{
$this->nowplaying = $nowplaying;
@ -679,7 +693,7 @@ class Station
/**
* @param array|null $automation_settings
*/
public function setAutomationSettings(array $automation_settings = null)
public function setAutomationSettings(array $automation_settings = null): void
{
$this->automation_settings = $automation_settings;
}
@ -695,7 +709,7 @@ class Station
/**
* @param int|null $automation_timestamp
*/
public function setAutomationTimestamp(int $automation_timestamp = null)
public function setAutomationTimestamp(int $automation_timestamp = null): void
{
$this->automation_timestamp = $automation_timestamp;
}
@ -711,7 +725,7 @@ class Station
/**
* @param bool $enable_requests
*/
public function setEnableRequests(bool $enable_requests)
public function setEnableRequests(bool $enable_requests): void
{
$this->enable_requests = $enable_requests;
}
@ -727,7 +741,7 @@ class Station
/**
* @param int|null $request_delay
*/
public function setRequestDelay(int $request_delay = null)
public function setRequestDelay(int $request_delay = null): void
{
$this->request_delay = $request_delay;
}
@ -735,7 +749,7 @@ class Station
/**
* @return int|null
*/
public function getRequestThreshold()
public function getRequestThreshold(): ?int
{
return $this->request_threshold;
}
@ -743,7 +757,7 @@ class Station
/**
* @param int|null $request_threshold
*/
public function setRequestThreshold(int $request_threshold = null)
public function setRequestThreshold(int $request_threshold = null): void
{
$this->request_threshold = $request_threshold;
}
@ -759,7 +773,7 @@ class Station
/**
* @param int $disconnect_deactivate_streamer
*/
public function setDisconnectDeactivateStreamer(int $disconnect_deactivate_streamer)
public function setDisconnectDeactivateStreamer(int $disconnect_deactivate_streamer): void
{
$this->disconnect_deactivate_streamer = $disconnect_deactivate_streamer;
}
@ -775,7 +789,7 @@ class Station
/**
* @param bool $enable_streamers
*/
public function setEnableStreamers(bool $enable_streamers)
public function setEnableStreamers(bool $enable_streamers): void
{
$this->enable_streamers = $enable_streamers;
}
@ -807,7 +821,7 @@ class Station
/**
* @param bool $enable_public_page
*/
public function setEnablePublicPage(bool $enable_public_page)
public function setEnablePublicPage(bool $enable_public_page): void
{
$this->enable_public_page = $enable_public_page;
}
@ -823,7 +837,7 @@ class Station
/**
* @param bool $needs_restart
*/
public function setNeedsRestart(bool $needs_restart)
public function setNeedsRestart(bool $needs_restart): void
{
$this->needs_restart = $needs_restart;
}
@ -839,7 +853,7 @@ class Station
/**
* @param bool $has_started
*/
public function setHasStarted(bool $has_started)
public function setHasStarted(bool $has_started): void
{
$this->has_started = $has_started;
}
@ -860,6 +874,162 @@ class Station
$this->api_history_items = $api_history_items;
}
/**
* @return string|null
*/
public function getStorageQuota(): ?string
{
$raw_quota = $this->getRawStorageQuota();
return ($raw_quota instanceof BigInteger)
? Quota::getReadableSize($raw_quota)
: '';
}
/**
* @return BigInteger|null
*/
public function getRawStorageQuota(): ?BigInteger
{
$size = $this->storage_quota;
return (null !== $size)
? BigInteger::of($size)
: null;
}
/**
* @param BigInteger|string|null $storage_quota
*/
public function setStorageQuota($storage_quota): void
{
$storage_quota = (string)Quota::convertFromReadableSize($storage_quota);
$this->storage_quota = !empty($storage_quota) ? $storage_quota : null;
}
/**
* @return string|null
*/
public function getStorageUsed(): ?string
{
$raw_size = $this->getRawStorageUsed();
return ($raw_size instanceof BigInteger)
? Quota::getReadableSize($raw_size)
: '';
}
/**
* @return BigInteger|null
*/
public function getRawStorageUsed(): ?BigInteger
{
$size = $this->storage_used;
if (null === $size) {
$total_size = disk_total_space($this->getRadioMediaDir());
$used_size = disk_free_space($this->getRadioMediaDir());
return BigInteger::of($total_size)
->minus($used_size)
->abs();
}
return BigInteger::of($size);
}
/**
* @param BigInteger|string|null $storage_used
*/
public function setStorageUsed($storage_used): void
{
$storage_used = (string)Quota::convertFromReadableSize($storage_used);
$this->storage_used = !empty($storage_used) ? $storage_used : null;
}
/**
* Increment the current used storage total.
*
* @param BigInteger|string|int $new_storage_amount
*/
public function addStorageUsed($new_storage_amount): void
{
$current_storage_used = $this->getRawStorageUsed();
if (null === $current_storage_used) {
return;
}
$this->storage_used = (string)$current_storage_used->plus($new_storage_amount);
}
/**
* Decrement the current used storage total.
*
* @param BigInteger|string|int $amount_to_remove
*/
public function removeStorageUsed($amount_to_remove): void
{
$current_storage_used = $this->getRawStorageUsed();
if (null === $current_storage_used) {
return;
}
$this->storage_used = (string)$current_storage_used->minus($amount_to_remove);
}
/**
* @return string
*/
public function getStorageAvailable(): string
{
$raw_size = $this->getRawStorageAvailable();
return ($raw_size instanceof BigInteger)
? Quota::getReadableSize($raw_size)
: '';
}
/**
* @return BigInteger|null
*/
public function getRawStorageAvailable(): ?BigInteger
{
$quota = $this->getRawStorageQuota();
$total_space = disk_total_space($this->getRadioMediaDir());
if ($quota === null || $quota->compareTo($total_space) === 1) {
return BigInteger::of($total_space);
}
return $quota;
}
/**
* @return bool
*/
public function isStorageFull(): bool
{
$available = $this->getRawStorageAvailable();
if ($available === null) {
return true;
}
$used = $this->getRawStorageUsed();
if ($used === null) {
return false;
}
return ($used->compareTo($available) !== -1);
}
/**
* @return int
*/
public function getStorageUsePercentage(): int
{
return Quota::getPercentage($this->getRawStorageUsed(), $this->getRawStorageAvailable());
}
/**
* @return Collection
*/

View File

@ -0,0 +1,9 @@
<?php
namespace App\Exception;
use Monolog\Logger;
class OutOfSpace extends \Azura\Exception
{
protected $logger_level = Logger::INFO;
}

View File

@ -244,57 +244,6 @@ class DoctrineEntityNormalizer extends AbstractNormalizer
}
}
} else {
// Handle a scalar value that should possibly be converted.
$field_info = $context[self::CLASS_METADATA]->fieldMappings[$field] ?? [];
switch ($field_info['type']) {
case 'datetime':
case 'date':
if (!($value instanceof \DateTime)) {
if ($value) {
if (!is_numeric($value)) {
$value = strtotime($value . ' UTC');
}
$value = \DateTime::createFromFormat(\DateTime::ATOM, gmdate(\DateTime::ATOM, (int)$value));
} else {
$value = null;
}
}
break;
case 'string':
if (is_string($value) && $field_info['length'] && strlen($value) > $field_info['length']) {
$value = substr($value, 0, $field_info['length']);
}
break;
case 'decimal':
case 'float':
if ($value !== null) {
if (is_numeric($value)) {
$value = (float)$value;
} elseif (empty($value)) {
$value = ($field_info['nullable']) ? NULL : 0.0;
}
}
break;
case 'integer':
case 'smallint':
case 'bigint':
if ($value !== null) {
$value = (int)$value;
}
break;
case 'boolean':
if ($value !== null) {
$value = (bool)$value;
}
break;
}
$this->_set($object, $field, $value);
}
}

84
src/Radio/Quota.php Normal file
View File

@ -0,0 +1,84 @@
<?php
namespace App\Radio;
use Brick\Math;
/**
* Static utility class for managing quotas.
*/
class Quota
{
/**
* @param Math\BigInteger $size
* @param Math\BigInteger $total
* @return int
*/
public static function getPercentage(Math\BigInteger $size, Math\BigInteger $total): int
{
if (-1 !== $size->compareTo($total)) {
return 100;
}
$size = $size->toBigDecimal();
return $size->dividedBy($total, 2, Math\RoundingMode::HALF_CEILING)
->multipliedBy(100)
->toInt();
}
/**
* @param Math\BigInteger $bytes
* @param int $decimals
* @return string
*/
public static function getReadableSize(Math\BigInteger $bytes, $decimals = 1): string
{
$bytes_str = (string)$bytes;
$size = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
$factor = (int)floor((strlen($bytes_str) - 1) / 3);
if (isset($size[$factor])) {
$byte_divisor = Math\BigInteger::of(1024)->power($factor);
$size_string = $bytes->toBigDecimal()
->dividedBy($byte_divisor, $decimals, Math\RoundingMode::HALF_DOWN);
return $size_string.' '.$size[$factor];
}
return $bytes_str;
}
/**
* @param string $size
* @return Math\BigInteger|null
*/
public static function convertFromReadableSize($size): ?Math\BigInteger
{
if ($size instanceof Math\BigInteger) {
return $size;
}
if (empty($size)) {
return null;
}
// Remove the non-unit characters from the size.
$unit = preg_replace('/[^bkmgtpezy]/i', '', $size);
// Remove the non-numeric characters from the size.
$size = preg_replace('/[^0-9\\.]/', '', $size);
if ($unit) {
// Find the position of the unit in the ordered string which is the power
// of magnitude to multiply a kilobyte by.
$byte_power = stripos('bkmgtpezy', $unit[0]);
$byte_multiplier = Math\BigInteger::of(1024)->power($byte_power);
return Math\BigDecimal::of($size)
->multipliedBy($byte_multiplier)
->toBigInteger();
}
return Math\BigInteger::of($size);
}
}

View File

@ -170,6 +170,7 @@ class Flow
return [
'path' => $finalPath,
'filename' => $originalFileName,
'size' => filesize($finalPath),
];
}

View File

@ -4,6 +4,8 @@ namespace App\Sync\Task;
use App\MessageQueue;
use App\Message;
use App\Radio\Filesystem;
use App\Radio\Quota;
use Brick\Math\BigInteger;
use Doctrine\Common\Persistence\Mapping\MappingException;
use Doctrine\ORM\EntityManager;
use App\Entity;
@ -89,6 +91,7 @@ class Media extends AbstractTask
$fs->flushAllCaches();
$stats = [
'total_size' => '0',
'total_files' => 0,
'unchanged' => 0,
'updated' => 0,
@ -97,7 +100,11 @@ class Media extends AbstractTask
];
$music_files = [];
$total_size = BigInteger::zero();
foreach($fs->listContents('media://', true) as $file) {
$total_size = $total_size->plus($file['size']);
if ('file' !== $file['type']) {
continue;
}
@ -106,6 +113,10 @@ class Media extends AbstractTask
$music_files[$path_hash] = $file;
}
$station->setStorageUsed($total_size);
$this->em->persist($station);
$stats['total_size'] = $total_size.' ('.Quota::getReadableSize($total_size).')';
$stats['total_files'] = count($music_files);
/** @var Entity\Repository\StationMediaRepository $media_repo */

View File

@ -1,7 +1,5 @@
$(function() {
var CSRF = '<?=$csrf ?>';
var MAX_UPLOAD_SIZE = <?=$max_upload_size ?>;
var $current_dir = '';
var appToolbar = new Vue({
@ -283,6 +281,9 @@ $(function() {
searchPhrase: $('input.search-field').val()
};
},
headers: {
'Accept': 'application/json'
},
withCredentials: true,
allowDuplicateUploads: true,
fileParameterName: 'file_data'
@ -311,12 +312,13 @@ $(function() {
var $row = $('#file_upload_'+file.uniqueIdentifier);
$row.remove();
var $error_row = renderFileSizeErrorRow(file, message);
var message_json = JSON.parse(message);
var $error_row = renderFileSizeErrorRow(file, message_json.message);
$('#upload_progress').append($error_row);
window.setTimeout(function(){ $error_row.fadeOut(); },5000);
});
flow.on('error', function(message, file, chunk) {
console.log(message, file, chunk);
console.error(message, file, chunk);
});
function renderFileUploadRow(file) {
@ -328,8 +330,8 @@ $(function() {
function renderFileSizeErrorRow(file, message) {
return $row = $('<div id="file_upload_error_'+file.uniqueIdentifier+'" class="error" />')
.append( $('<span class="fileuploadname" />').text('Error: ' + file.name))
.append( $('<span/>').text(message) );
.append( $('<div class="fileuploadname" />').text('Error: ' + file.name))
.append( $('<div/>').text(message) );
}
function list() {

View File

@ -11,7 +11,6 @@ $assets
->load('fancybox')
->addInlineJs($this->fetch('stations/files/index.js', [
'csrf' => $csrf,
'max_upload_size' => $max_upload_size,
'playlists' => $playlists,
]));
?>
@ -33,7 +32,7 @@ $assets
<span class="sr-only"><?=$space_percent ?>%</span>
</div>
</div>
<?=sprintf(__('%s of %s Used (%s Free)'), $space_used, $space_total, $space_free) ?>
<?=sprintf(__('%s of %s Used'), $space_used, $space_total) ?>
</div>
</div>