feat(admin): add instance wide dashboard with storage and bandwidth usage

* add DashboardCard component
* add instance wide podcasts and episodes numbers
* add app.storageLimit environment variable
* divide bytes by 1000 instead of 1024 in stats sql queries

closes #216
This commit is contained in:
Yassine Doghri 2022-07-06 15:29:15 +00:00
parent 3d363f2efe
commit b1a6c02e56
57 changed files with 395 additions and 140 deletions

View File

@ -451,4 +451,9 @@ class App extends BaseConfig
];
public string $theme = 'pine';
/**
* Storage limit in Gigabytes
*/
public ?int $storageLimit = null;
}

View File

@ -179,9 +179,9 @@ if (! function_exists('publication_button')) {
break;
}
return <<<CODE_SAMPLE
return <<<HTML
<Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button>
CODE_SAMPLE;
HTML;
}
}
@ -205,7 +205,7 @@ if (! function_exists('publication_status_banner')) {
case 'scheduled':
$bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode');
$bannerText = lang('Podcast.publication_status_banner.scheduled', [
'publication_date' => local_time($publicationDate),
'publication_date' => local_datetime($publicationDate),
], null, false);
$linkRoute = route_to('podcast-publish_edit', $podcastId);
$linkLabel = lang('Podcast.publish_edit');
@ -218,7 +218,7 @@ if (! function_exists('publication_status_banner')) {
break;
}
return <<<CODE_SAMPLE
return <<<HTML
<div class="flex items-center px-12 py-1 border-b bg-stripes-gray border-subtle" role="alert">
<p class="text-gray-900">
<span class="text-xs font-semibold tracking-wide uppercase">{$bannerDisclaimer}</span>
@ -226,7 +226,7 @@ if (! function_exists('publication_status_banner')) {
</p>
<a href="{$linkRoute}" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline">{$linkLabel}</a>
</div>
CODE_SAMPLE;
HTML;
}
}
@ -321,7 +321,7 @@ if (! function_exists('audio_player')) {
$language = service('request')
->getLocale();
return <<<CODE_SAMPLE
return <<<HTML
<vm-player
id="castopod-vm-player"
theme="light"
@ -346,7 +346,7 @@ if (! function_exists('audio_player')) {
</vm-controls>
</vm-ui>
</vm-player>
CODE_SAMPLE;
HTML;
}
}
@ -361,16 +361,60 @@ if (! function_exists('relative_time')) {
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ISO8601);
return <<<CODE_SAMPLE
return <<<HTML
<time-ago class="{$class}" datetime="{$datetime}">
<time
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</time-ago>
CODE_SAMPLE;
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('local_datetime')) {
function local_datetime(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request'
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ISO8601);
return <<<HTML
<local-time datetime="{$datetime}"
weekday="long"
month="long"
day="numeric"
year="numeric"
hour="numeric"
minute="numeric">
<time
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</local-time>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('local_date')) {
function local_date(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request'
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
return <<<HTML
<time title="{$time}">{$translatedDate}</time>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('explicit_badge')) {
@ -381,9 +425,9 @@ if (! function_exists('explicit_badge')) {
}
$explicitLabel = lang('Common.explicit');
return <<<CODE_SAMPLE
return <<<HTML
<span class="px-1 text-xs font-semibold leading-tight tracking-wider uppercase border md:border-white/50 {$class}">{$explicitLabel}</span>
CODE_SAMPLE;
HTML;
}
}

View File

@ -8,7 +8,6 @@ declare(strict_types=1);
* @link https://castopod.org/
*/
use CodeIgniter\I18n\Time;
if (! function_exists('get_browser_language')) {
/**
@ -281,41 +280,16 @@ if (! function_exists('format_bytes')) {
/**
* Adapted from https://stackoverflow.com/a/2510459
*/
function formatBytes(float $bytes, int $precision = 2): string
function formatBytes(float $bytes, bool $is_binary = false, int $precision = 2): string
{
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
$units = $is_binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = floor(($bytes ? log($bytes) : 0) / log($is_binary ? 1024 : 1000));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
$bytes /= pow($is_binary ? 1024 : 1000, $pow);
return round($bytes, $precision) . $units[$pow];
}
}
if (! function_exists('local_time')) {
function local_time(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request'
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ISO8601);
return <<<CODE_SAMPLE
<local-time datetime="{$datetime}"
weekday="long"
month="long"
day="numeric"
year="numeric"
hour="numeric"
minute="numeric">
<time
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</local-time>
CODE_SAMPLE;
}
}

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M21 9.5v3c0 2.485-4.03 4.5-9 4.5s-9-2.015-9-4.5v-3c0 2.485 4.03 4.5 9 4.5s9-2.015 9-4.5zm-18 5c0 2.485 4.03 4.5 9 4.5s9-2.015 9-4.5v3c0 2.485-4.03 4.5-9 4.5s-9-2.015-9-4.5v-3zm9-2.5c-4.97 0-9-2.015-9-4.5S7.03 3 12 3s9 2.015 9 4.5-4.03 4.5-9 4.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM10.622 8.415l4.879 3.252a.4.4 0 0 1 0 .666l-4.88 3.252a.4.4 0 0 1-.621-.332V8.747a.4.4 0 0 1 .622-.332z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Views\Components;
use ViewComponents\Component;
class DashboardCard extends Component
{
protected ?string $href = null;
protected string $glyph;
protected string $title;
protected string $subtitle;
public function setSubtitle(string $value): void
{
$this->subtitle = html_entity_decode($value);
}
public function render(): string
{
$glyph = icon($this->glyph, 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base');
if ($this->href !== null && $this->href !== '') {
$chevronRight = icon('chevron-right');
$viewLang = lang('Common.view');
return <<<HTML
<a href="{$this->href}" class="flex items-center justify-between w-full max-w-sm p-4 bg-elevated focus:ring-accent rounded-xl border-3 border-subtle group">
<div class="flex items-start">{$glyph}<div class="flex flex-col ml-2"><div class="flex items-center"><span class="text-xs font-semibold leading-loose tracking-wider uppercase">{$this->title}</span><div class="inline-flex items-center ml-4 transition -translate-x-full group-hover:translate-x-0 group-focus:translate-x-0"><span class="-ml-2 text-xs lowercase transition opacity-0 group-hover:opacity-100 group-focus:opacity-100">{$viewLang}</span>{$chevronRight}</div></div><p class="text-xs">{$this->subtitle}</p></div></div>
<div class="mx-2 text-5xl font-bold">{$this->slot}</div>
</a>
HTML;
}
return <<<HTML
<div class="flex items-center justify-between w-full max-w-sm p-4 bg-elevated rounded-xl border-3 border-subtle">
<div class="flex items-start">{$glyph}<div class="flex flex-col ml-2"><span class="text-xs font-semibold leading-loose tracking-wider uppercase">{$this->title}</span><p class="text-xs">{$this->subtitle}</p></div></div>
<div class="mx-2 text-5xl font-bold">{$this->slot}</div>
</div>
HTML;
}
}

View File

@ -26,6 +26,7 @@ return static function (ECSConfig $ecsConfig): void {
__DIR__ . '/app/Views/Components/*',
__DIR__ . '/modules/**/Views/Components/*',
__DIR__ . '/themes/**/Views/Components/*',
__DIR__ . '/app/Helpers/components_helper.php'
],
LineLengthFixer::class => [

View File

@ -19,7 +19,7 @@ $routes->group(
'namespace' => 'Modules\Admin\Controllers',
],
function ($routes): void {
$routes->get('/', 'HomeController', [
$routes->get('/', 'DashboardController', [
'as' => 'admin',
]);

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\Admin\Controllers;
use App\Models\EpisodeModel;
use App\Models\MediaModel;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
class DashboardController extends BaseController
{
public function index(): string
{
$podcastsData = [];
$podcastsCount = (new PodcastModel())->builder()
->countAll();
$podcastsLastPublishedAt = (new PodcastModel())->builder()
->select('MAX(published_at) as last_published_at')
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->get()
->getResultArray()[0]['last_published_at'];
$podcastsData['number_of_podcasts'] = (int) $podcastsCount;
$podcastsData['last_published_at'] = $podcastsLastPublishedAt === null ? null : new Time(
$podcastsLastPublishedAt
);
$episodesData = [];
$episodesCount = (new EpisodeModel())->builder()
->countAll();
$episodesLastPublishedAt = (new EpisodeModel())->builder()
->select('MAX(published_at) as last_published_at')
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->get()
->getResultArray()[0]['last_published_at'];
$episodesData['number_of_episodes'] = (int) $episodesCount;
$episodesData['last_published_at'] = $episodesLastPublishedAt === null ? null : new Time(
$episodesLastPublishedAt
);
$totalUploaded = (new MediaModel())->builder()
->selectSum('file_size')
->get()
->getResultArray()[0];
$appStorageLimit = config('App')
->storageLimit;
if ($appStorageLimit === null || $appStorageLimit < 0) {
$storageLimitBytes = disk_free_space('./');
} else {
$storageLimitBytes = $appStorageLimit * 1000000000;
}
$storageData = [
'limit' => formatBytes((int) $storageLimitBytes),
'percentage' => round((((int) $totalUploaded['file_size']) / $storageLimitBytes) * 100, 0),
'total_uploaded' => formatBytes((int) $totalUploaded['file_size']),
];
$onlyPodcastId = null;
if ($podcastsData['number_of_podcasts'] === 1) {
$onlyPodcastId = (new PodcastModel())->first()
->id;
}
$data = [
'podcastsData' => $podcastsData,
'episodesData' => $episodesData,
'storageData' => $storageData,
'onlyPodcastId' => $onlyPodcastId,
];
return view('dashboard', $data);
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace Modules\Admin\Controllers;
use CodeIgniter\HTTP\RedirectResponse;
class HomeController extends BaseController
{
public function index(): RedirectResponse
{
session()->keepFlashdata('message');
return redirect()->route('podcast-list');
}
}

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'اختر أسلوب التفاعل',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'لوحة التحكم الإدارية',
'home' => 'لوحة التحكم الإدارية',
'welcome_message' => 'أهلًا بك في المنطقة الإدارية!',
'choose_interact' => 'اختر أسلوب التفاعل',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'O lenn',
],
'size_limit' => 'Bevenn ar vent: {0}.',
'choose_interact' => 'Dibabit penaos interaktiñ',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Taolenn-stur',
'home' => 'Taolenn-stur',
'welcome_message' => 'Degemer mat en daolenn-stur!',
'choose_interact' => 'Dibabit penaos interaktiñ',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Spielt',
],
'size_limit' => 'Größenlimit: {0}.',
'choose_interact' => 'Mit welchem Podcast-Profil wollen Sie handeln',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Adminübersicht',
'home' => 'Adminübersicht',
'welcome_message' => 'Willkommen im Administrationsbereich!',
'choose_interact' => 'Mit welchem Podcast-Profil wollen Sie handeln',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Αναπαράγεται',
],
'size_limit' => 'Όριο μεγέθους: {0}.',
'choose_interact' => 'Επιλέξτε τον τρόπο αλληλεπίδρασης',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Πίνακας ελέγχου διαχειριστή',
'home' => 'Πίνακας ελέγχου διαχειριστή',
'welcome_message' => 'Καλώς ήρθατε στην περιοχή διαχείρισης!',
'choose_interact' => 'Επιλέξτε τον τρόπο αλληλεπίδρασης',
];

View File

@ -35,4 +35,6 @@ return [
'by_weekday' => 'By week day (for the past 60 days)',
'by_hour' => 'By time of day (for the past 60 days)',
'podcast_by_bandwidth' => 'Daily used bandwidth (in MB)',
'total_storage_by_month' => 'Monthly storage (in MB)',
'total_bandwidth_by_month' => 'Monthly used bandwidth (in MB)',
];

View File

@ -46,4 +46,6 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
'view' => 'View',
];

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'podcasts' => [
'title' => 'Podcasts',
'not_found' => 'No published podcast',
'last_published' => 'Last published on {lastPublicationDate}',
],
'episodes' => [
'title' => 'Episodes',
'not_found' => 'No published episode',
'last_published' => 'Last published on {lastPublicationDate}',
],
'storage' => [
'title' => 'Storage',
'subtitle' => '{totalUploaded} out of {totalStorage}',
],
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Reproduciendo',
],
'size_limit' => 'Límite de tamaño: {0}.',
'choose_interact' => 'Elige cómo interactuar',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Panel de administración',
'home' => 'Panel de administración',
'welcome_message' => '¡Bienvenido al área de administración!',
'choose_interact' => 'Elige cómo interactuar',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'En cours',
],
'size_limit' => 'Taille maximale: {0}.',
'choose_interact' => 'Choisissez comment interagir',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Tableau de bord',
'home' => 'Tableau de bord',
'welcome_message' => 'Bienvenue dans ladministration!',
'choose_interact' => 'Choisissez comment interagir',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Admin dashboard',
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Admin dashboard',
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Wordt afgespeeld',
],
'size_limit' => 'Maximale grootte: {0}.',
'choose_interact' => 'Kies hoe de interactie moet worden',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Beheerder overzicht',
'home' => 'Beheerder overzicht',
'welcome_message' => 'Welkom bij de beheerder omgeving!',
'choose_interact' => 'Kies hoe de interactie moet worden',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Spelar',
],
'size_limit' => 'Maks storleik: {0}.',
'choose_interact' => 'Vel korleis du vil samhandla',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Styringspanel',
'home' => 'Styringspanel',
'welcome_message' => 'Velkomen til styrarområdet!',
'choose_interact' => 'Vel korleis du vil samhandla',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Admin dashboard',
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Odtwarzanie',
],
'size_limit' => 'Limit rozmiaru: {0}.',
'choose_interact' => 'Wybierz sposób interakcji',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Pulpit administratora',
'home' => 'Pulpit administratora',
'welcome_message' => 'Witamy w panelu administracyjnym!',
'choose_interact' => 'Wybierz sposób interakcji',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Reproduzindo',
],
'size_limit' => 'Limite de tamanho: {0}.',
'choose_interact' => 'Escolha como interagir',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Painel de administração',
'home' => 'Painel de administração',
'welcome_message' => 'Bem-vindo à área de administração!',
'choose_interact' => 'Escolha como interagir',
];

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'dashboard' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Admin dashboard',
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Выберите как взаимодействовать',
];

View File

@ -9,7 +9,6 @@ declare(strict_types=1);
*/
return [
'dashboard' => 'Панель Администратора',
'home' => 'Панель Администратора',
'welcome_message' => 'Добро пожаловать в панель администрирования!',
'choose_interact' => 'Выберите как взаимодействовать',
];

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'dashboard' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
'choose_interact' => 'Choose how to interact',
];

View File

@ -46,4 +46,5 @@ return [
'playing' => 'Playing',
],
'size_limit' => 'Size limit: {0}.',
'choose_interact' => 'Choose how to interact',
];

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'home' => 'Admin dashboard',
'welcome_message' => 'Welcome to the admin area!',
];

View File

@ -19,7 +19,7 @@ $routes->addPlaceholder(
);
$routes->addPlaceholder(
'filter',
'\bWeekly|\bYearly|\bByDay|\bByWeekday|\bByMonth|\bByAppWeekly|\bByAppYearly|\bByOsWeekly|\bByDeviceWeekly|\bBots|\bByServiceWeekly|\bBandwidthByDay|\bUniqueListenersByDay|\bUniqueListenersByMonth|\bTotalListeningTimeByDay|\bTotalListeningTimeByMonth|\bByDomainWeekly|\bByDomainYearly',
'\bWeekly|\bYearly|\bByDay|\bByWeekday|\bByMonth|\bByAppWeekly|\bByAppYearly|\bByOsWeekly|\bByDeviceWeekly|\bBots|\bByServiceWeekly|\bBandwidthByDay|\bUniqueListenersByDay|\bUniqueListenersByMonth|\bTotalListeningTimeByDay|\bTotalListeningTimeByMonth|\bByDomainWeekly|\bByDomainYearly|\bTotalBandwidthByMonth|\bTotalStorageByMonth',
);
$routes->group('', [
@ -53,6 +53,10 @@ $routes->group('', [
);
});
$routes->get(config('Analytics')->gateway . '/(:class)/(:filter)', 'AnalyticsController::getData/$1/$2', [
'as' => 'analytics-data-instance',
]);
// Route for podcast audio file analytics (/audio/pack(podcast_id,episode_id,bytes_threshold,filesize,duration,date)/podcast_folder/filename.mp3)
$routes->head(
'audio/(:base64)/(:any)',

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\ResponseInterface;
@ -17,6 +18,8 @@ use CodeIgniter\Model;
class AnalyticsController extends Controller
{
use ResponseTrait;
protected Model $analyticsModel;
protected string $methodName = '';
@ -27,6 +30,12 @@ class AnalyticsController extends Controller
throw PageNotFoundException::forPageNotFound();
}
if (! is_numeric($params[0])) {
$this->analyticsModel = model('Analytics' . $params[0] . 'Model');
$this->methodName = 'getData' . $params[1];
return $this->{$method}();
}
$this->analyticsModel = model('Analytics' . $params[1] . 'Model');
$this->methodName = 'getData' . (count($params) >= 3 ? $params[2] : '');
@ -36,14 +45,18 @@ class AnalyticsController extends Controller
);
}
public function getData(int $podcastId, ?int $episodeId = null): ResponseInterface
public function getData(?int $podcastId = null, ?int $episodeId = null): ResponseInterface
{
$methodName = $this->methodName;
if ($episodeId === null) {
return $this->response->setJSON($this->analyticsModel->{$methodName}($podcastId));
if ($podcastId === null) {
return $this->respond($this->analyticsModel->{$methodName}());
}
return $this->response->setJSON($this->analyticsModel->{$methodName}($podcastId, $episodeId));
if ($episodeId === null) {
return $this->respond($this->analyticsModel->{$methodName}($podcastId));
}
return $this->respond($this->analyticsModel->{$methodName}($podcastId, $episodeId));
}
}

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Modules\Analytics\Models;
use App\Entities\Media\BaseMedia;
use App\Models\MediaModel;
use CodeIgniter\Model;
use Modules\Analytics\Entities\AnalyticsPodcasts;
@ -93,7 +95,7 @@ class AnalyticsPodcastModel extends Model
public function getDataBandwidthByDay(int $podcastId): array
{
if (! ($found = cache("{$podcastId}_analytics_podcast_by_bandwidth"))) {
$found = $this->select('date as labels, round(bandwidth / 1048576, 1) as `values`')
$found = $this->select('date as labels, ROUND(bandwidth / 1000000, 2) as `values`')
->where([
'podcast_id' => $podcastId,
'date >' => date('Y-m-d', strtotime('-60 days')),
@ -235,4 +237,48 @@ class AnalyticsPodcastModel extends Model
return $found;
}
/**
* Gets total bandwidth data for instance
*
* @return AnalyticsPodcasts[]
*/
public function getDataTotalBandwidthByMonth(): array
{
if (! ($found = cache('analytics_total_bandwidth_by_month'))) {
$found = $this->select(
'DATE_FORMAT(updated_at,"%Y-%m") as labels, ROUND(sum(bandwidth) / 1000000, 2) as `values`'
)
->groupBy('labels')
->orderBy('labels', 'ASC')
->findAll();
cache()
->save('analytics_total_bandwidth_by_month', $found, 600);
}
return $found;
}
/**
* Get total storage
*
* @return BaseMedia[]
*/
public function getDataTotalStorageByMonth(): array
{
if (! ($found = cache('analytics_total_storage_by_month'))) {
$found = (new MediaModel())->select(
'DATE_FORMAT(uploaded_at,"%Y-%m") as labels, ROUND(sum(file_size) / 1000000, 2) as `values`'
)
->groupBy('labels')
->orderBy('labels', 'ASC')
->findAll();
cache()
->save('analytics_total_storage_by_month', $found, 600);
}
return $found;
}
}

View File

@ -42,7 +42,7 @@
CODE_SAMPLE;
}
$interactAsText = lang('Admin.choose_interact');
$interactAsText = lang('Common.choose_interact');
$route = route_to('interact-as-actor');
$csrfField = csrf_field();

View File

@ -1,6 +1,10 @@
<?php declare(strict_types=1);
$navigation = [
'dashboard' => [
'icon' => 'dashboard',
'items' => ['admin'],
],
'podcasts' => [
'icon' => 'mic',
'items' => ['podcast-list', 'podcast-create', 'podcast-import'],

View File

@ -2,13 +2,42 @@
<?= $this->extend('_layout') ?>
<?= $this->section('title') ?>
<?= lang('Admin.dashboard') ?>
<?= lang('Dashboard.home') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Admin.dashboard') ?>
<?= lang('Dashboard.home') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= lang('Admin.welcome_message') ?>
<div class="flex flex-wrap items-start gap-4">
<DashboardCard href="<?= route_to('podcast-list') ?>" glyph="mic" title="<?= lang('Dashboard.podcasts.title') ?>" subtitle="<?= $podcastsData['last_published_at'] ? esc(lang('Dashboard.podcasts.last_published', [
'lastPublicationDate' => local_date($podcastsData['last_published_at']),
], null, false)) : lang('Dashboard.podcasts.not_found') ?>"><?= $podcastsData['number_of_podcasts'] ?></DashboardCard>
<DashboardCard href="<?= $onlyPodcastId === null ? '' : route_to('episode-list', $onlyPodcastId) ?>" glyph="play" title="<?= lang('Dashboard.episodes.title') ?>" subtitle="<?= $episodesData['last_published_at'] ? esc(lang('Dashboard.episodes.last_published', [
'lastPublicationDate' => local_date($episodesData['last_published_at']),
], null, false)) : lang('Dashboard.episodes.not_found') ?>"><?= $episodesData['number_of_episodes'] ?></DashboardCard>
<DashboardCard glyph="database" title="<?= lang('Dashboard.storage.title') ?>" subtitle="<?= lang('Dashboard.storage.subtitle', [
'totalUploaded' => $storageData['total_uploaded'],
'totalStorage' => $storageData['limit'],
]) ?>"><?= $storageData['percentage'] ?>%</DashboardCard>
</div>
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<Charts.XY class="col-span-1" title="<?= lang('Charts.total_storage_by_month') ?>" dataUrl="<?= route_to(
'analytics-data-instance',
'Podcast',
'TotalStorageByMonth',
) ?>" />
<Charts.XY class="col-span-1" title="<?= lang('Charts.total_bandwidth_by_month') ?>" dataUrl="<?= route_to(
'analytics-data-instance',
'Podcast',
'TotalBandwidthByMonth',
) ?>" />
</div>
<?= service('vite')
->asset('js/charts.ts', 'js') ?>
<?= $this->endsection() ?>

View File

@ -21,12 +21,12 @@
name="audio_file"
label="<?= lang('Episode.form.audio_file') ?>"
hint="<?= lang('Episode.form.audio_file_hint') ?>"
helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size())]) ?>"
helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size(), true)]) ?>"
type="file"
accept=".mp3,.m4a"
required="true"
data-max-size="<?= file_upload_max_size() ?>"
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size())]) ?>" />
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" />
<Forms.Field
name="cover"

View File

@ -25,11 +25,11 @@
name="audio_file"
label="<?= lang('Episode.form.audio_file') ?>"
hint="<?= lang('Episode.form.audio_file_hint') ?>"
helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size())]) ?>"
helper="<?= lang('Common.size_limit', [formatBytes(file_upload_max_size(), true)]) ?>"
type="file"
accept=".mp3,.m4a"
data-max-size="<?= file_upload_max_size() ?>"
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size())]) ?>" />
data-max-size-error="<?= lang('Episode.form.file_size_error', [formatBytes(file_upload_max_size(), true)]) ?>" />
<Forms.Field
name="cover"

View File

@ -6,7 +6,7 @@ $podcastNavigation = [
'items' => ['podcast-view', 'podcast-edit', 'podcast-persons-manage'],
],
'episodes' => [
'icon' => 'mic',
'icon' => 'play-circle',
'items' => ['episode-list', 'episode-create'],
],
'analytics' => [

View File

@ -37,7 +37,7 @@
CODE_SAMPLE;
}
$interactAsText = lang('Admin.choose_interact');
$interactAsText = lang('Common.choose_interact');
$route = route_to('interact-as-actor');
$csrfField = csrf_field();