From b1a6c02e56fdc01a7ff69fa7e7dd8ea71380b7ba Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Wed, 6 Jul 2022 15:29:15 +0000
Subject: [PATCH] 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
---
 app/Config/App.php                            |  5 ++
 app/Helpers/components_helper.php             | 66 ++++++++++++---
 app/Helpers/misc_helper.php                   | 34 +-------
 app/Resources/icons/database.svg              |  6 ++
 app/Resources/icons/play-circle.svg           |  6 ++
 app/Views/Components/DashboardCard.php        | 46 +++++++++++
 ecs.php                                       |  1 +
 modules/Admin/Config/Routes.php               |  2 +-
 .../Admin/Controllers/DashboardController.php | 82 +++++++++++++++++++
 modules/Admin/Controllers/HomeController.php  | 22 -----
 modules/Admin/Language/ar/Common.php          |  1 +
 .../Language/ar/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/br/Common.php          |  1 +
 .../Language/br/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/de/Common.php          |  1 +
 .../Language/de/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/el/Common.php          |  1 +
 .../Language/el/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/en/Charts.php          |  2 +
 modules/Admin/Language/en/Common.php          |  2 +
 modules/Admin/Language/en/Dashboard.php       | 28 +++++++
 modules/Admin/Language/es/Common.php          |  1 +
 .../Language/es/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/fr/Common.php          |  1 +
 .../Language/fr/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/id/Common.php          |  1 +
 .../{en/Admin.php => id/Dashboard.php}        |  3 +-
 modules/Admin/Language/it/Common.php          |  1 +
 .../{id/Admin.php => it/Dashboard.php}        |  3 +-
 modules/Admin/Language/nl/Common.php          |  1 +
 .../Language/nl/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/nn-NO/Common.php       |  1 +
 .../nn-NO/{Admin.php => Dashboard.php}        |  3 +-
 modules/Admin/Language/oc/Common.php          |  1 +
 .../Language/oc/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/pl/Common.php          |  1 +
 .../Language/pl/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/pt-BR/Common.php       |  1 +
 .../pt-BR/{Admin.php => Dashboard.php}        |  3 +-
 modules/Admin/Language/pt/Admin.php           | 15 ----
 modules/Admin/Language/pt/Common.php          |  1 +
 .../{it/Admin.php => pt/Dashboard.php}        |  3 +-
 modules/Admin/Language/ru/Common.php          |  1 +
 .../Language/ru/{Admin.php => Dashboard.php}  |  3 +-
 modules/Admin/Language/sv/Admin.php           | 15 ----
 modules/Admin/Language/sv/Common.php          |  1 +
 modules/Admin/Language/sv/Dashboard.php       | 14 ++++
 modules/Analytics/Config/Routes.php           |  6 +-
 .../Controllers/AnalyticsController.php       | 19 ++++-
 .../Models/AnalyticsPodcastModel.php          | 48 ++++++++++-
 themes/cp_admin/_partials/_nav_header.php     |  2 +-
 themes/cp_admin/_sidebar.php                  |  4 +
 themes/cp_admin/dashboard.php                 | 35 +++++++-
 themes/cp_admin/episode/create.php            |  4 +-
 themes/cp_admin/episode/edit.php              |  4 +-
 themes/cp_admin/podcast/_sidebar.php          |  2 +-
 themes/cp_app/_admin_navbar.php               |  2 +-
 57 files changed, 394 insertions(+), 139 deletions(-)
 create mode 100644 app/Resources/icons/database.svg
 create mode 100644 app/Resources/icons/play-circle.svg
 create mode 100644 app/Views/Components/DashboardCard.php
 create mode 100644 modules/Admin/Controllers/DashboardController.php
 delete mode 100644 modules/Admin/Controllers/HomeController.php
 rename modules/Admin/Language/ar/{Admin.php => Dashboard.php} (68%)
 rename modules/Admin/Language/br/{Admin.php => Dashboard.php} (73%)
 rename modules/Admin/Language/de/{Admin.php => Dashboard.php} (69%)
 rename modules/Admin/Language/el/{Admin.php => Dashboard.php} (63%)
 create mode 100644 modules/Admin/Language/en/Dashboard.php
 rename modules/Admin/Language/es/{Admin.php => Dashboard.php} (72%)
 rename modules/Admin/Language/fr/{Admin.php => Dashboard.php} (72%)
 rename modules/Admin/Language/{en/Admin.php => id/Dashboard.php} (73%)
 rename modules/Admin/Language/{id/Admin.php => it/Dashboard.php} (73%)
 rename modules/Admin/Language/nl/{Admin.php => Dashboard.php} (70%)
 rename modules/Admin/Language/nn-NO/{Admin.php => Dashboard.php} (72%)
 rename modules/Admin/Language/oc/{Admin.php => Dashboard.php} (73%)
 rename modules/Admin/Language/pl/{Admin.php => Dashboard.php} (71%)
 rename modules/Admin/Language/pt-BR/{Admin.php => Dashboard.php} (71%)
 delete mode 100644 modules/Admin/Language/pt/Admin.php
 rename modules/Admin/Language/{it/Admin.php => pt/Dashboard.php} (73%)
 rename modules/Admin/Language/ru/{Admin.php => Dashboard.php} (66%)
 delete mode 100644 modules/Admin/Language/sv/Admin.php
 create mode 100644 modules/Admin/Language/sv/Dashboard.php

diff --git a/app/Config/App.php b/app/Config/App.php
index fe5c59a8a7..cf921c7c6e 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -451,4 +451,9 @@ class App extends BaseConfig
     ];
 
     public string $theme = 'pine';
+
+    /**
+     * Storage limit in Gigabytes
+     */
+    public ?int $storageLimit = null;
 }
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 6bef701359..6989ee4ea7 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -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;
     }
 }
 
diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php
index e69e9e87e8..7975bdc123 100644
--- a/app/Helpers/misc_helper.php
+++ b/app/Helpers/misc_helper.php
@@ -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;
-    }
-}
diff --git a/app/Resources/icons/database.svg b/app/Resources/icons/database.svg
new file mode 100644
index 0000000000..6dc449b48c
--- /dev/null
+++ b/app/Resources/icons/database.svg
@@ -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>
\ No newline at end of file
diff --git a/app/Resources/icons/play-circle.svg b/app/Resources/icons/play-circle.svg
new file mode 100644
index 0000000000..5d5f703938
--- /dev/null
+++ b/app/Resources/icons/play-circle.svg
@@ -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>
\ No newline at end of file
diff --git a/app/Views/Components/DashboardCard.php b/app/Views/Components/DashboardCard.php
new file mode 100644
index 0000000000..474022378c
--- /dev/null
+++ b/app/Views/Components/DashboardCard.php
@@ -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;
+    }
+}
diff --git a/ecs.php b/ecs.php
index 6abb2da582..8e2da3b116 100644
--- a/ecs.php
+++ b/ecs.php
@@ -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 => [
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index b3ffcd13ae..d6c58357a5 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -19,7 +19,7 @@ $routes->group(
         'namespace' => 'Modules\Admin\Controllers',
     ],
     function ($routes): void {
-        $routes->get('/', 'HomeController', [
+        $routes->get('/', 'DashboardController', [
             'as' => 'admin',
         ]);
 
diff --git a/modules/Admin/Controllers/DashboardController.php b/modules/Admin/Controllers/DashboardController.php
new file mode 100644
index 0000000000..14057bcd0e
--- /dev/null
+++ b/modules/Admin/Controllers/DashboardController.php
@@ -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);
+    }
+}
diff --git a/modules/Admin/Controllers/HomeController.php b/modules/Admin/Controllers/HomeController.php
deleted file mode 100644
index 9248cb4c22..0000000000
--- a/modules/Admin/Controllers/HomeController.php
+++ /dev/null
@@ -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');
-    }
-}
diff --git a/modules/Admin/Language/ar/Common.php b/modules/Admin/Language/ar/Common.php
index 9021d57efb..8df4418606 100644
--- a/modules/Admin/Language/ar/Common.php
+++ b/modules/Admin/Language/ar/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'اختر أسلوب التفاعل',
 ];
diff --git a/modules/Admin/Language/ar/Admin.php b/modules/Admin/Language/ar/Dashboard.php
similarity index 68%
rename from modules/Admin/Language/ar/Admin.php
rename to modules/Admin/Language/ar/Dashboard.php
index 971ecb1018..43d1496397 100644
--- a/modules/Admin/Language/ar/Admin.php
+++ b/modules/Admin/Language/ar/Dashboard.php
@@ -9,7 +9,6 @@ declare(strict_types=1);
  */
 
 return [
-    'dashboard' => 'لوحة التحكم الإدارية',
+    'home' => 'لوحة التحكم الإدارية',
     'welcome_message' => 'أهلًا بك في المنطقة الإدارية!',
-    'choose_interact' => 'اختر أسلوب التفاعل',
 ];
diff --git a/modules/Admin/Language/br/Common.php b/modules/Admin/Language/br/Common.php
index 7a0ca7e0e1..93bb776d22 100644
--- a/modules/Admin/Language/br/Common.php
+++ b/modules/Admin/Language/br/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'O lenn',
     ],
     'size_limit' => 'Bevenn ar vent: {0}.',
+    'choose_interact' => 'Dibabit penaos interaktiñ',
 ];
diff --git a/modules/Admin/Language/br/Admin.php b/modules/Admin/Language/br/Dashboard.php
similarity index 73%
rename from modules/Admin/Language/br/Admin.php
rename to modules/Admin/Language/br/Dashboard.php
index 3773855d69..dd55b106b6 100644
--- a/modules/Admin/Language/br/Admin.php
+++ b/modules/Admin/Language/br/Dashboard.php
@@ -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ñ',
 ];
diff --git a/modules/Admin/Language/de/Common.php b/modules/Admin/Language/de/Common.php
index 64ee6f3e85..71549ddb5b 100644
--- a/modules/Admin/Language/de/Common.php
+++ b/modules/Admin/Language/de/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Spielt',
     ],
     'size_limit' => 'Größenlimit: {0}.',
+    'choose_interact' => 'Mit welchem Podcast-Profil wollen Sie handeln',
 ];
diff --git a/modules/Admin/Language/de/Admin.php b/modules/Admin/Language/de/Dashboard.php
similarity index 69%
rename from modules/Admin/Language/de/Admin.php
rename to modules/Admin/Language/de/Dashboard.php
index ec6d5e7955..b3e24a6971 100644
--- a/modules/Admin/Language/de/Admin.php
+++ b/modules/Admin/Language/de/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/el/Common.php b/modules/Admin/Language/el/Common.php
index e19b3e7360..126fc83ae7 100644
--- a/modules/Admin/Language/el/Common.php
+++ b/modules/Admin/Language/el/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Αναπαράγεται',
     ],
     'size_limit' => 'Όριο μεγέθους: {0}.',
+    'choose_interact' => 'Επιλέξτε τον τρόπο αλληλεπίδρασης',
 ];
diff --git a/modules/Admin/Language/el/Admin.php b/modules/Admin/Language/el/Dashboard.php
similarity index 63%
rename from modules/Admin/Language/el/Admin.php
rename to modules/Admin/Language/el/Dashboard.php
index 7b4a8544fa..516b5368d9 100644
--- a/modules/Admin/Language/el/Admin.php
+++ b/modules/Admin/Language/el/Dashboard.php
@@ -9,7 +9,6 @@ declare(strict_types=1);
  */
 
 return [
-    'dashboard' => 'Πίνακας ελέγχου διαχειριστή',
+    'home' => 'Πίνακας ελέγχου διαχειριστή',
     'welcome_message' => 'Καλώς ήρθατε στην περιοχή διαχείρισης!',
-    'choose_interact' => 'Επιλέξτε τον τρόπο αλληλεπίδρασης',
 ];
diff --git a/modules/Admin/Language/en/Charts.php b/modules/Admin/Language/en/Charts.php
index d9a75a1d84..4b33530ef0 100644
--- a/modules/Admin/Language/en/Charts.php
+++ b/modules/Admin/Language/en/Charts.php
@@ -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)',
 ];
diff --git a/modules/Admin/Language/en/Common.php b/modules/Admin/Language/en/Common.php
index d97bcf991f..596c8bcdec 100644
--- a/modules/Admin/Language/en/Common.php
+++ b/modules/Admin/Language/en/Common.php
@@ -46,4 +46,6 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
+    'view' => 'View',
 ];
diff --git a/modules/Admin/Language/en/Dashboard.php b/modules/Admin/Language/en/Dashboard.php
new file mode 100644
index 0000000000..881073fd26
--- /dev/null
+++ b/modules/Admin/Language/en/Dashboard.php
@@ -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}',
+    ],
+];
diff --git a/modules/Admin/Language/es/Common.php b/modules/Admin/Language/es/Common.php
index 05648953c2..61a42889f9 100644
--- a/modules/Admin/Language/es/Common.php
+++ b/modules/Admin/Language/es/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Reproduciendo',
     ],
     'size_limit' => 'Límite de tamaño: {0}.',
+    'choose_interact' => 'Elige cómo interactuar',
 ];
diff --git a/modules/Admin/Language/es/Admin.php b/modules/Admin/Language/es/Dashboard.php
similarity index 72%
rename from modules/Admin/Language/es/Admin.php
rename to modules/Admin/Language/es/Dashboard.php
index 83f9f8d57e..05ea795513 100644
--- a/modules/Admin/Language/es/Admin.php
+++ b/modules/Admin/Language/es/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/fr/Common.php b/modules/Admin/Language/fr/Common.php
index 04725db62e..4187008ef4 100644
--- a/modules/Admin/Language/fr/Common.php
+++ b/modules/Admin/Language/fr/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'En cours',
     ],
     'size_limit' => 'Taille maximale : {0}.',
+    'choose_interact' => 'Choisissez comment interagir',
 ];
diff --git a/modules/Admin/Language/fr/Admin.php b/modules/Admin/Language/fr/Dashboard.php
similarity index 72%
rename from modules/Admin/Language/fr/Admin.php
rename to modules/Admin/Language/fr/Dashboard.php
index 3b37a2b240..358a486b6a 100644
--- a/modules/Admin/Language/fr/Admin.php
+++ b/modules/Admin/Language/fr/Dashboard.php
@@ -9,7 +9,6 @@ declare(strict_types=1);
  */
 
 return [
-    'dashboard' => 'Tableau de bord',
+    'home' => 'Tableau de bord',
     'welcome_message' => 'Bienvenue dans l’administration !',
-    'choose_interact' => 'Choisissez comment interagir',
 ];
diff --git a/modules/Admin/Language/id/Common.php b/modules/Admin/Language/id/Common.php
index d97bcf991f..be498dacf3 100644
--- a/modules/Admin/Language/id/Common.php
+++ b/modules/Admin/Language/id/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
 ];
diff --git a/modules/Admin/Language/en/Admin.php b/modules/Admin/Language/id/Dashboard.php
similarity index 73%
rename from modules/Admin/Language/en/Admin.php
rename to modules/Admin/Language/id/Dashboard.php
index 5e3942371f..7f28d24dfe 100644
--- a/modules/Admin/Language/en/Admin.php
+++ b/modules/Admin/Language/id/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/it/Common.php b/modules/Admin/Language/it/Common.php
index d97bcf991f..be498dacf3 100644
--- a/modules/Admin/Language/it/Common.php
+++ b/modules/Admin/Language/it/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
 ];
diff --git a/modules/Admin/Language/id/Admin.php b/modules/Admin/Language/it/Dashboard.php
similarity index 73%
rename from modules/Admin/Language/id/Admin.php
rename to modules/Admin/Language/it/Dashboard.php
index 5e3942371f..7f28d24dfe 100644
--- a/modules/Admin/Language/id/Admin.php
+++ b/modules/Admin/Language/it/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/nl/Common.php b/modules/Admin/Language/nl/Common.php
index 9ef0b7721d..ac06748dee 100644
--- a/modules/Admin/Language/nl/Common.php
+++ b/modules/Admin/Language/nl/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Wordt afgespeeld',
     ],
     'size_limit' => 'Maximale grootte: {0}.',
+    'choose_interact' => 'Kies hoe de interactie moet worden',
 ];
diff --git a/modules/Admin/Language/nl/Admin.php b/modules/Admin/Language/nl/Dashboard.php
similarity index 70%
rename from modules/Admin/Language/nl/Admin.php
rename to modules/Admin/Language/nl/Dashboard.php
index 8e50419d8a..b5c5ef8182 100644
--- a/modules/Admin/Language/nl/Admin.php
+++ b/modules/Admin/Language/nl/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/nn-NO/Common.php b/modules/Admin/Language/nn-NO/Common.php
index 813bb910aa..14026246c9 100644
--- a/modules/Admin/Language/nn-NO/Common.php
+++ b/modules/Admin/Language/nn-NO/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Spelar',
     ],
     'size_limit' => 'Maks storleik: {0}.',
+    'choose_interact' => 'Vel korleis du vil samhandla',
 ];
diff --git a/modules/Admin/Language/nn-NO/Admin.php b/modules/Admin/Language/nn-NO/Dashboard.php
similarity index 72%
rename from modules/Admin/Language/nn-NO/Admin.php
rename to modules/Admin/Language/nn-NO/Dashboard.php
index 2b69c6971f..776337abeb 100644
--- a/modules/Admin/Language/nn-NO/Admin.php
+++ b/modules/Admin/Language/nn-NO/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/oc/Common.php b/modules/Admin/Language/oc/Common.php
index d97bcf991f..be498dacf3 100644
--- a/modules/Admin/Language/oc/Common.php
+++ b/modules/Admin/Language/oc/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
 ];
diff --git a/modules/Admin/Language/oc/Admin.php b/modules/Admin/Language/oc/Dashboard.php
similarity index 73%
rename from modules/Admin/Language/oc/Admin.php
rename to modules/Admin/Language/oc/Dashboard.php
index 5e3942371f..7f28d24dfe 100644
--- a/modules/Admin/Language/oc/Admin.php
+++ b/modules/Admin/Language/oc/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/pl/Common.php b/modules/Admin/Language/pl/Common.php
index f015f1f889..00e9c9f2a2 100644
--- a/modules/Admin/Language/pl/Common.php
+++ b/modules/Admin/Language/pl/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Odtwarzanie',
     ],
     'size_limit' => 'Limit rozmiaru: {0}.',
+    'choose_interact' => 'Wybierz sposób interakcji',
 ];
diff --git a/modules/Admin/Language/pl/Admin.php b/modules/Admin/Language/pl/Dashboard.php
similarity index 71%
rename from modules/Admin/Language/pl/Admin.php
rename to modules/Admin/Language/pl/Dashboard.php
index cb262679da..9c9a1268cf 100644
--- a/modules/Admin/Language/pl/Admin.php
+++ b/modules/Admin/Language/pl/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/pt-BR/Common.php b/modules/Admin/Language/pt-BR/Common.php
index e57238eb17..82cb61806e 100644
--- a/modules/Admin/Language/pt-BR/Common.php
+++ b/modules/Admin/Language/pt-BR/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Reproduzindo',
     ],
     'size_limit' => 'Limite de tamanho: {0}.',
+    'choose_interact' => 'Escolha como interagir',
 ];
diff --git a/modules/Admin/Language/pt-BR/Admin.php b/modules/Admin/Language/pt-BR/Dashboard.php
similarity index 71%
rename from modules/Admin/Language/pt-BR/Admin.php
rename to modules/Admin/Language/pt-BR/Dashboard.php
index 432b6a925d..6625b47d28 100644
--- a/modules/Admin/Language/pt-BR/Admin.php
+++ b/modules/Admin/Language/pt-BR/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/pt/Admin.php b/modules/Admin/Language/pt/Admin.php
deleted file mode 100644
index 5e3942371f..0000000000
--- a/modules/Admin/Language/pt/Admin.php
+++ /dev/null
@@ -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',
-];
diff --git a/modules/Admin/Language/pt/Common.php b/modules/Admin/Language/pt/Common.php
index d97bcf991f..be498dacf3 100644
--- a/modules/Admin/Language/pt/Common.php
+++ b/modules/Admin/Language/pt/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
 ];
diff --git a/modules/Admin/Language/it/Admin.php b/modules/Admin/Language/pt/Dashboard.php
similarity index 73%
rename from modules/Admin/Language/it/Admin.php
rename to modules/Admin/Language/pt/Dashboard.php
index 5e3942371f..7f28d24dfe 100644
--- a/modules/Admin/Language/it/Admin.php
+++ b/modules/Admin/Language/pt/Dashboard.php
@@ -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',
 ];
diff --git a/modules/Admin/Language/ru/Common.php b/modules/Admin/Language/ru/Common.php
index d97bcf991f..a020253294 100644
--- a/modules/Admin/Language/ru/Common.php
+++ b/modules/Admin/Language/ru/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Выберите как взаимодействовать',
 ];
diff --git a/modules/Admin/Language/ru/Admin.php b/modules/Admin/Language/ru/Dashboard.php
similarity index 66%
rename from modules/Admin/Language/ru/Admin.php
rename to modules/Admin/Language/ru/Dashboard.php
index 1babcd9fc6..102be71dd6 100644
--- a/modules/Admin/Language/ru/Admin.php
+++ b/modules/Admin/Language/ru/Dashboard.php
@@ -9,7 +9,6 @@ declare(strict_types=1);
  */
 
 return [
-    'dashboard' => 'Панель Администратора',
+    'home' => 'Панель Администратора',
     'welcome_message' => 'Добро пожаловать в панель администрирования!',
-    'choose_interact' => 'Выберите как взаимодействовать',
 ];
diff --git a/modules/Admin/Language/sv/Admin.php b/modules/Admin/Language/sv/Admin.php
deleted file mode 100644
index 5e3942371f..0000000000
--- a/modules/Admin/Language/sv/Admin.php
+++ /dev/null
@@ -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',
-];
diff --git a/modules/Admin/Language/sv/Common.php b/modules/Admin/Language/sv/Common.php
index d97bcf991f..be498dacf3 100644
--- a/modules/Admin/Language/sv/Common.php
+++ b/modules/Admin/Language/sv/Common.php
@@ -46,4 +46,5 @@ return [
         'playing' => 'Playing',
     ],
     'size_limit' => 'Size limit: {0}.',
+    'choose_interact' => 'Choose how to interact',
 ];
diff --git a/modules/Admin/Language/sv/Dashboard.php b/modules/Admin/Language/sv/Dashboard.php
new file mode 100644
index 0000000000..7f28d24dfe
--- /dev/null
+++ b/modules/Admin/Language/sv/Dashboard.php
@@ -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!',
+];
diff --git a/modules/Analytics/Config/Routes.php b/modules/Analytics/Config/Routes.php
index 1261e9abeb..352682eb18 100644
--- a/modules/Analytics/Config/Routes.php
+++ b/modules/Analytics/Config/Routes.php
@@ -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)',
diff --git a/modules/Analytics/Controllers/AnalyticsController.php b/modules/Analytics/Controllers/AnalyticsController.php
index 06dfd1802b..fb0a03150a 100644
--- a/modules/Analytics/Controllers/AnalyticsController.php
+++ b/modules/Analytics/Controllers/AnalyticsController.php
@@ -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 ($podcastId === null) {
+            return $this->respond($this->analyticsModel->{$methodName}());
+        }
+
         if ($episodeId === null) {
-            return $this->response->setJSON($this->analyticsModel->{$methodName}($podcastId));
+            return $this->respond($this->analyticsModel->{$methodName}($podcastId));
         }
 
-        return $this->response->setJSON($this->analyticsModel->{$methodName}($podcastId, $episodeId));
+        return $this->respond($this->analyticsModel->{$methodName}($podcastId, $episodeId));
     }
 }
diff --git a/modules/Analytics/Models/AnalyticsPodcastModel.php b/modules/Analytics/Models/AnalyticsPodcastModel.php
index 493dd5da31..fee1ae4452 100644
--- a/modules/Analytics/Models/AnalyticsPodcastModel.php
+++ b/modules/Analytics/Models/AnalyticsPodcastModel.php
@@ -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;
+    }
 }
diff --git a/themes/cp_admin/_partials/_nav_header.php b/themes/cp_admin/_partials/_nav_header.php
index 8280862898..6b40fafce1 100644
--- a/themes/cp_admin/_partials/_nav_header.php
+++ b/themes/cp_admin/_partials/_nav_header.php
@@ -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();
 
diff --git a/themes/cp_admin/_sidebar.php b/themes/cp_admin/_sidebar.php
index 8f53e8153d..f557e5e9b4 100644
--- a/themes/cp_admin/_sidebar.php
+++ b/themes/cp_admin/_sidebar.php
@@ -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'],
diff --git a/themes/cp_admin/dashboard.php b/themes/cp_admin/dashboard.php
index 7dce6c0d1d..bebdc2d0a0 100644
--- a/themes/cp_admin/dashboard.php
+++ b/themes/cp_admin/dashboard.php
@@ -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() ?>
diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php
index 61f050730b..a35ab5a884 100644
--- a/themes/cp_admin/episode/create.php
+++ b/themes/cp_admin/episode/create.php
@@ -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"
diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php
index 9191635d31..64a78eef3a 100644
--- a/themes/cp_admin/episode/edit.php
+++ b/themes/cp_admin/episode/edit.php
@@ -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"
diff --git a/themes/cp_admin/podcast/_sidebar.php b/themes/cp_admin/podcast/_sidebar.php
index 619e1f4cf7..bbe873e889 100644
--- a/themes/cp_admin/podcast/_sidebar.php
+++ b/themes/cp_admin/podcast/_sidebar.php
@@ -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' => [
diff --git a/themes/cp_app/_admin_navbar.php b/themes/cp_app/_admin_navbar.php
index f2a4100426..690ac5909e 100644
--- a/themes/cp_app/_admin_navbar.php
+++ b/themes/cp_app/_admin_navbar.php
@@ -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();
 
-- 
GitLab