Dashboard Overhaul (#3651)

This commit is contained in:
Buster "Silver Eagle" Neece 2021-01-10 20:41:58 -06:00 committed by GitHub
parent 6b53bc4cea
commit 7862c6d515
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 998 additions and 813 deletions

View File

@ -505,7 +505,7 @@ return [
'StationProfile' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'moment'],
'require' => ['vue-component-common', 'bootstrap-vue', 'moment', 'fancybox'],
// Auto-managed by Assets
],
@ -514,4 +514,10 @@ return [
'require' => ['vue-component-common', 'bootstrap-vue'],
// Auto-managed by Assets
],
'Dashboard' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'chartjs'],
// Auto-managed by Assets
],
];

View File

@ -37,6 +37,25 @@ return function (App $app) {
$group->get('/time', Controller\Api\IndexController::class . ':timeAction')
->setName('api:index:time');
$group->group(
'/frontend',
function (RouteCollectorProxy $group) {
$group->group(
'/dashboard',
function (RouteCollectorProxy $group) {
$group->get('/charts', Controller\Api\Frontend\Dashboard\ChartsAction::class)
->setName('api:frontend:dashboard:charts');
$group->get('/notifications', Controller\Api\Frontend\Dashboard\NotificationsAction::class)
->setName('api:frontend:dashboard:notifications');
$group->get('/stations', Controller\Api\Frontend\Dashboard\StationsAction::class)
->setName('api:frontend:dashboard:stations');
}
);
}
)->add(Middleware\RequireLogin::class);
$group->group(
'/internal',
function (RouteCollectorProxy $group) {

View File

@ -6,13 +6,13 @@ use Slim\App;
use Slim\Routing\RouteCollectorProxy;
return function (App $app) {
$app->get('/', Controller\Frontend\IndexController::class . ':indexAction')
$app->get('/', Controller\Frontend\IndexAction::class)
->setName('home');
$app->group(
'',
function (RouteCollectorProxy $group) {
$group->get('/dashboard', Controller\Frontend\DashboardController::class . ':indexAction')
$group->get('/dashboard', Controller\Frontend\DashboardAction::class)
->setName('dashboard');
$group->get('/logout', Controller\Frontend\Account\LogoutAction::class)

View File

@ -1691,12 +1691,11 @@
"integrity": "sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA=="
},
"axios": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz",
"integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": {
"follow-redirects": "1.5.10",
"is-buffer": "^2.0.2"
"follow-redirects": "^1.10.0"
}
},
"babel-plugin-dynamic-import-node": {
@ -1806,6 +1805,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
@ -2156,6 +2156,7 @@
"anymatch": "^2.0.0",
"async-each": "^1.0.1",
"braces": "^2.3.2",
"fsevents": "^1.2.7",
"glob-parent": "^3.1.0",
"inherits": "^2.0.3",
"is-binary-path": "^1.0.0",
@ -3343,7 +3344,8 @@
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"fill-range": {
"version": "4.0.0",
@ -3499,27 +3501,9 @@
}
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"requires": {
"debug": "=3.1.0"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
"integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
},
"for-in": {
"version": "1.0.2",
@ -3624,6 +3608,16 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -4326,9 +4320,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"interpret": {
"version": "1.4.0",
@ -4385,11 +4379,6 @@
"binary-extensions": "^1.0.0"
}
},
"is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
},
"is-core-module": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz",
@ -5210,7 +5199,8 @@
"nan": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ=="
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
"optional": true
},
"nanomatch": {
"version": "1.2.13",
@ -7793,6 +7783,7 @@
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.1.2",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
@ -7809,6 +7800,12 @@
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"optional": true
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",

View File

@ -19,7 +19,7 @@
"@fullcalendar/timegrid": "^4.4.2",
"@fullcalendar/vue": "^4.4.2",
"autosize": "^4.0.2",
"axios": "^0.18.1",
"axios": "^0.21.1",
"bootstrap": "^4.5.2",
"bootstrap-daterangepicker": "^3.1.0",
"bootstrap-notify": "^3.1.3",

236
frontend/vue/Dashboard.vue Normal file
View File

@ -0,0 +1,236 @@
<template>
<div id="dashboard">
<section class="card mb-4" role="region">
<div class="card-header bg-primary-dark d-flex flex-wrap align-items-center">
<a class="flex-shrink-0" href="http://www.gravatar.com/" target="_blank">
<img :src="userAvatar" style="width: 64px; height: auto;" alt="">
</a>
<div class="flex-fill ml-3">
<h2 class="card-title mt-0">{{ userName }}</h2>
<h3 class="card-subtitle">{{ userEmail }}</h3>
</div>
<div class="flex-md-shrink-0 mt-3 mt-md-0">
<a class="btn btn-bg" role="button" :href="profileUrl">
<i class="material-icons" aria-hidden="true">account_circle</i>
<translate key="dashboard_btn_my_account">My Account</translate>
</a>
<a v-if="showAdmin" class="btn btn-bg ml-2" role="button" :href="adminUrl">
<i class="material-icons" aria-hidden="true">settings</i>
<translate key="dashboard_btn_administration">Administration</translate>
</a>
</div>
</div>
<template v-if="!notificationsLoading && notifications.length > 0">
<div v-for="notification in notifications" class="card-body d-flex align-items-center" :class="'alert-'+notification.type" role="alert">
<div class="flex-shrink-0 mr-3" v-if="'info' === notification.type">
<i class="material-icons lg" aria-hidden="true">info</i>
</div>
<div class="flex-shrink-0 mr-3" v-else>
<i class="material-icons lg" aria-hidden="true">warning</i>
</div>
<div class="flex-fill">
<h4>{{ notification.title }}</h4>
<p class="card-text" v-html="notification.body"></p>
</div>
<div v-if="notification.actionLabel && notification.actionUrl" class="flex-shrink-0 ml-3">
<b-button :href="notification.actionUrl" target="_blank" size="sm" variant="inverse">
{{ notification.actionLabel }}
</b-button>
</div>
</div>
</template>
</section>
<section class="card mb-4" role="region" v-if="showCharts">
<div class="card-header bg-primary-dark d-flex align-items-center">
<div class="flex-fill">
<h3 class="card-title">
<translate key="dashboard_header_listeners_per_station">Listeners Per Station</translate>
</h3>
</div>
<div class="flex-shrink-0">
<b-button variant="outline-default" size="sm" class="py-2" @click="toggleCharts">{{ langShowHideCharts }}</b-button>
</div>
</div>
<b-collapse id="charts" v-model="chartsVisible">
<b-overlay variant="card" :show="chartsLoading">
<div class="card-body py-5" v-if="chartsLoading">
&nbsp;
</div>
<b-tabs pills card lazy v-else>
<b-tab :title="langAverageListenersTab" active>
<time-series-chart style="width: 100%;" :data="chartsData.average.metrics">
<span v-html="chartsData.average.alt"></span>
</time-series-chart>
</b-tab>
<b-tab :title="langUniqueListenersTab">
<time-series-chart style="width: 100%;" :data="chartsData.unique.metrics">
<span v-html="chartsData.unique.alt"></span>
</time-series-chart>
</b-tab>
</b-tabs>
</b-overlay>
</b-collapse>
</section>
<section class="card" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="dashboard_header_stations">Station Overview</translate>
</h3>
</div>
<div class="card-actions" v-if="showAdmin">
<a class="btn btn-outline-primary" :href="addStationUrl">
<i class="material-icons" aria-hidden="true">add</i>
<translate key="dashboard_btn_add_station">Add Station</translate>
</a>
</div>
<data-table ref="datatable" id="station_playlists" paginated :fields="stationsFields" :responsive="false"
:api-url="stationsUrl">
<template v-slot:cell(station)="{ item }">
<div class="d-flex align-items-center">
<div class="flex-shrink-0 pr-2">
<a class="file-icon btn-audio" href="#" :data-url="item.station.listen_url"
@click.prevent="playAudio(item.station.listen_url)" :title="langPlayPause">
<i class="material-icons lg align-middle" aria-hidden="true">play_circle_filled</i>
</a>
</div>
<div class="flex-fill">
<big>{{ item.station.name }}</big><br>
<a :href="item.links.public" target="_blank">
<translate key="dashboard_link_public_page">Public Page</translate>
</a>
</div>
</div>
</template>
<template v-slot:cell(listeners)="{ item }">
<span class="nowplaying-listeners">{{ item.listeners.current }}</span>
</template>
<template v-slot:cell(now_playing)="{ item }">
<div v-if="item.now_playing.song.title != ''">
<strong><span class="nowplaying-title">{{ item.now_playing.song.title }}</span></strong><br>
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
</div>
<div v-else>
<strong><span class="nowplaying-title">{{ item.now_playing.song.text }}</span></strong>
</div>
</template>
<template v-slot:cell(actions)="{ item }">
<a class="btn btn-primary" v-bind:href="item.links.manage">
<translate key="dashboard_btn_manage_station">Manage</translate>
</a>
</template>
</data-table>
</section>
</div>
</template>
<script>
import TimeSeriesChart from './components/TimeSeriesChart';
import DataTable from './components/DataTable';
import axios from 'axios';
import store from 'store';
export default {
components: { DataTable, TimeSeriesChart },
props: {
userName: String,
userEmail: String,
userAvatar: String,
profileUrl: String,
adminUrl: String,
showAdmin: Boolean,
notificationsUrl: String,
showCharts: Boolean,
chartsUrl: String,
addStationUrl: String,
stationsUrl: String
},
data () {
return {
chartsLoading: true,
chartsVisible: null,
chartsData: {
average: {
metrics: [],
alt: ''
},
unique: {
metrics: [],
alt: ''
}
},
notificationsLoading: true,
notifications: [],
stationsTimeout: null,
stationsFields: [
{
key: 'station',
label: this.$gettext('Station Name'),
sortable: true
},
{
key: 'listeners',
label: this.$gettext('Listeners'),
sortable: true
},
{ key: 'now_playing', label: this.$gettext('Now Playing'), sortable: false },
{ key: 'actions', label: this.$gettext('Actions'), sortable: false }
]
};
},
computed: {
langAverageListenersTab () {
return this.$gettext('Average Listeners');
},
langUniqueListenersTab () {
return this.$gettext('Unique Listeners');
},
langPlayPause () {
return this.$gettext('Play/Pause');
},
langShowHideCharts () {
if (this.chartsVisible) {
return this.$gettext('Hide Charts');
}
return this.$gettext('Show Charts');
}
},
mounted () {
moment.tz.setDefault('UTC');
if (store.enabled && store.get('dashboard_show_chart') !== undefined) {
this.chartsVisible = store.get('dashboard_show_chart', true);
} else {
this.chartsVisible = true;
}
axios.get(this.chartsUrl).then((response) => {
this.chartsData = response.data;
this.chartsLoading = false;
}).catch((error) => {
console.error(error);
});
axios.get(this.notificationsUrl).then((response) => {
this.notifications = response.data;
this.notificationsLoading = false;
}).catch((error) => {
console.error(error);
});
},
methods: {
toggleCharts () {
this.chartsVisible = !this.chartsVisible;
if (store.enabled) {
store.set('dashboard_show_chart', this.chartsVisible);
}
},
playAudio (url) {
this.$eventHub.$emit('player_toggle', url);
}
}
};
</script>

View File

@ -56,7 +56,8 @@
<b-table ref="table" show-empty striped hover :selectable="selectable" :api-url="apiUrl" :per-page="perPage"
:current-page="currentPage" @row-selected="onRowSelected" :items="loadItems" :fields="visibleFields"
:empty-text="langNoRecords" :empty-filtered-text="langNoRecords" :responsive="responsive"
:no-provider-paging="handleClientSide" :no-provider-sorting="handleClientSide" :no-provider-filtering="handleClientSide"
:no-provider-paging="handleClientSide" :no-provider-sorting="handleClientSide"
:no-provider-filtering="handleClientSide"
tbody-tr-class="align-middle" thead-tr-class="align-middle" selected-variant=""
:filter="filter" @filtered="onFiltered" @refreshed="onRefreshed">
<template v-slot:head(selected)="data">
@ -182,15 +183,14 @@ export default {
data () {
let allFields = [];
_.forEach(this.fields, function (field) {
allFields.push({
key: field.key,
label: _.defaultTo(field.label, ''),
isRowHeader: _.defaultTo(field.isRowHeader, false),
sortable: _.defaultTo(field.sortable, false),
selectable: _.defaultTo(field.selectable, false),
visible: _.defaultTo(field.visible, true),
formatter: _.defaultTo(field.formatter, null)
});
allFields.push(_.defaults(_.clone(field), {
label: '',
isRowHeader: false,
sortable: false,
selectable: false,
visible: true,
formatter: null
}));
});
return {

View File

@ -0,0 +1,92 @@
<template>
<canvas ref="canvas">
<slot></slot>
</canvas>
</template>
<script>
import _ from 'lodash';
export default {
name: 'TimeSeriesChart',
inheritAttrs: true,
props: {
options: Object,
data: Array
},
data () {
return {
_chart: null
};
},
mounted () {
Chart.platform.disableCSSInjection = true;
this.renderChart();
},
methods: {
renderChart () {
const defaultOptions = {
type: 'line',
data: {
datasets: this.data
},
options: {
aspectRatio: 3,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
},
scales: {
xAxes: [{
type: 'time',
distribution: 'linear',
time: {
unit: 'day'
},
ticks: {
source: 'data',
autoSkip: true
}
}],
yAxes: [{
scaleLabel: {
display: true,
labelString: this.$gettext('Listeners')
},
ticks: {
min: 0
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
callbacks: {
label: function (tooltipItem, myData) {
let label = myData.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += parseFloat(tooltipItem.value).toFixed(2);
return label;
}
}
}
}
};
if (this._chart) this._chart.destroy();
let chartOptions = _.defaultsDeep(_.clone(this.options), defaultOptions);
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
}
},
beforeDestroy () {
if (this._chart) {
this._chart.destroy();
}
}
};
</script>

View File

@ -15,7 +15,8 @@ module.exports = {
StationPlaylists: './vue/StationPlaylists.vue',
StationStreamers: './vue/StationStreamers.vue',
StationOnDemand: './vue/StationOnDemand.vue',
StationProfile: './vue/StationProfile.vue'
StationProfile: './vue/StationProfile.vue',
Dashboard: './vue/Dashboard.vue'
},
resolve: {
extensions: ['*', '.js', '.vue', '.json']

View File

@ -0,0 +1,185 @@
<?php
namespace App\Controller\Api\Frontend\Dashboard;
use App\Acl;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\SimpleCache\CacheInterface;
class ChartsAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
CacheInterface $cache,
Entity\Repository\SettingsRepository $settingsRepo
): ResponseInterface {
$settings = $settingsRepo->readSettings();
$analyticsLevel = $settings->getAnalytics();
if ($analyticsLevel === Entity\Analytics::LEVEL_NONE) {
return $response->withStatus(403, 'Forbidden')
->withJson(new Entity\Api\Error(403, 'Analytics are disabled for this installation.'));
}
$user = $request->getUser();
$acl = $request->getAcl();
// Don't show stations the user can't manage.
$showAdmin = $acl->userAllowed($user, Acl::GLOBAL_VIEW);
/** @var Entity\Station[] $stations */
$stations = array_filter(
$em->getRepository(Entity\Station::class)->findAll(),
function ($station) use ($user, $acl) {
/** @var Entity\Station $station */
return $station->isEnabled() &&
$acl->userAllowed($user, Acl::STATION_VIEW, $station->getId());
}
);
// Generate unique cache ID for stations.
$stationIds = [];
foreach ($stations as $station) {
$stationIds[$station->getId()] = $station->getId();
}
$cacheName = 'homepage_metrics_' . implode(',', $stationIds);
if ($cache->has($cacheName)) {
$stationStats = $cache->get($cacheName);
} else {
$threshold = CarbonImmutable::parse('-180 days');
$stats = $em->createQuery(
<<<'DQL'
SELECT a.station_id, a.moment, a.number_avg, a.number_unique
FROM App\Entity\Analytics a
WHERE a.station_id IN (:stations)
AND a.type = :type
AND a.moment >= :threshold
DQL
)->setParameter('stations', $stationIds)
->setParameter('type', Entity\Analytics::INTERVAL_DAILY)
->setParameter('threshold', $threshold)
->getArrayResult();
$showAllStations = $showAdmin && count($stationIds) > 1;
$rawStats = [
'average' => [],
'unique' => [],
];
foreach ($stats as $row) {
$stationId = $row['station_id'];
/** @var CarbonImmutable $moment */
$moment = $row['moment'];
$sortableKey = $moment->format('Y-m-d');
$jsTimestamp = $moment->getTimestamp() * 1000;
$average = round($row['number_avg'], 2);
$unique = $row['number_unique'];
$rawStats['average'][$stationId][$sortableKey] = [
$jsTimestamp,
$average,
];
if (null !== $unique) {
$rawStats['unique'][$stationId][$sortableKey] = [
$jsTimestamp,
$unique,
];
}
if ($showAllStations) {
if (!isset($rawStats['average']['all'][$sortableKey])) {
$rawStats['average']['all'][$sortableKey] = [
$jsTimestamp,
0,
];
}
$rawStats['average']['all'][$sortableKey][1] += $average;
if (null !== $unique) {
if (!isset($stationStats['unique']['all'][$sortableKey])) {
$stationStats['unique']['all'][$sortableKey] = [
$jsTimestamp,
0,
];
}
$stationStats['unique']['all'][$sortableKey][1] += $unique;
}
}
}
$stationsInMetric = [];
if ($showAllStations) {
$stationsInMetric['all'] = __('All Stations');
}
foreach ($stations as $station) {
$stationsInMetric[$station->getId()] = $station->getName();
}
$stationStats = [
'average' => [
'metrics' => [],
'alt' => '',
],
'unique' => [
'metrics' => [],
'alt' => '',
],
];
foreach ($stationsInMetric as $stationId => $stationName) {
foreach ($rawStats as $statKey => $statRows) {
if (!isset($statRows[$stationId])) {
continue;
}
$series = [
'label' => $stationName,
'type' => 'line',
'fill' => false,
'data' => [],
];
$stationStats[$statKey]['alt'] .= '<p>' . $stationName . '</p>';
$stationStats[$statKey]['alt'] .= '<dl>';
ksort($statRows[$stationId]);
foreach ($statRows[$stationId] as $sortableKey => [$jsTimestamp, $value]) {
$series['data'][] = [
't' => $jsTimestamp,
'y' => $value,
];
$stationStats[$statKey]['alt'] .= sprintf(
'<dt><time data-original="%s">%s</time></dt>',
$jsTimestamp,
$sortableKey
);
$stationStats[$statKey]['alt'] .= '<dd>' . $value . ' ' . __('Listeners') . '</dd>';
}
$stationStats[$statKey]['alt'] .= '</dl>';
$stationStats[$statKey]['metrics'][] = $series;
}
}
$cache->set($cacheName, $stationStats, 600);
}
return $response->withJson($stationStats);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Controller\Api\Frontend\Dashboard;
use App\Event;
use App\EventDispatcher;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class NotificationsAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EventDispatcher $eventDispatcher
): ResponseInterface {
$event = new Event\GetNotifications($request);
$eventDispatcher->dispatch($event);
return $response->withJson(
$event->getNotifications()
);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Controller\Api\Frontend\Dashboard;
use App\Acl;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Paginator;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class StationsAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
Entity\ApiGenerator\NowPlayingApiGenerator $npApiGenerator
): ResponseInterface {
$router = $request->getRouter();
$acl = $request->getAcl();
$user = $request->getUser();
/** @var Entity\Station[] $stations */
$stations = array_filter(
$em->getRepository(Entity\Station::class)->findAll(),
function ($station) use ($user, $acl) {
/** @var Entity\Station $station */
return $station->isEnabled() &&
$acl->userAllowed($user, Acl::STATION_VIEW, $station->getId());
}
);
$viewStations = [];
foreach ($stations as $station) {
$np = $npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($request->getRouter()->getBaseUrl());
$row = new Entity\Api\Dashboard();
$row->fromParentObject($np);
$row->links = [
'public' => (string)$router->named('public:index', ['station_id' => $station->getShortName()]),
'manage' => (string)$router->named('stations:index:index', ['station_id' => $station->getId()]),
];
$viewStations[] = $row;
}
$searchPhrase = trim($request->getParam('searchPhrase', ''));
if (!empty($searchPhrase)) {
$viewStations = array_filter(
$viewStations,
function (Entity\Api\Dashboard $row) use ($searchPhrase) {
return false !== mb_stripos($row->station->name, $searchPhrase);
}
);
}
$sort = $request->getParam('sort');
usort(
$viewStations,
function (Entity\Api\Dashboard $a, Entity\Api\Dashboard $b) use ($sort) {
if ('listeners' === $sort) {
return $a->listeners->current <=> $b->listeners->current;
}
return $a->station->name <=> $b->station->name;
}
);
$sortDesc = ('desc' === strtolower($request->getParam('sortOrder', 'asc')));
if ($sortDesc) {
$viewStations = array_reverse($viewStations);
}
$paginator = Paginator::fromArray($viewStations, $request);
return $paginator->write($response);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Controller\Frontend;
use App\Acl;
use App\Entity;
use App\EventDispatcher;
use App\Http\Response;
use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class DashboardAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
EventDispatcher $eventDispatcher,
Entity\ApiGenerator\NowPlayingApiGenerator $npApiGenerator,
Entity\Repository\SettingsRepository $settingsRepo
): ResponseInterface {
$view = $request->getView();
$user = $request->getUser();
$acl = $request->getAcl();
// Detect current analytics level.
$settings = $settingsRepo->readSettings();
$analyticsLevel = $settings->getAnalytics();
$showCharts = $analyticsLevel !== Entity\Analytics::LEVEL_NONE;
return $view->renderToResponse(
$response,
'frontend/index/index',
[
'showAdmin' => $acl->userAllowed($user, Acl::GLOBAL_VIEW),
'showCharts' => $showCharts,
]
);
}
}

View File

@ -1,291 +0,0 @@
<?php
namespace App\Controller\Frontend;
use App\Acl;
use App\Entity;
use App\Event;
use App\EventDispatcher;
use App\Http\Response;
use App\Http\Router;
use App\Http\ServerRequest;
use App\Radio\Adapters;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Contracts\Cache\CacheInterface;
class DashboardController
{
protected EntityManagerInterface $em;
protected Entity\Settings $settings;
protected Acl $acl;
protected Router $router;
protected Adapters $adapter_manager;
protected EventDispatcher $dispatcher;
public function __construct(
EntityManagerInterface $em,
Acl $acl,
Entity\Repository\SettingsRepository $settingsRepo,
Adapters $adapter_manager,
EventDispatcher $dispatcher
) {
$this->em = $em;
$this->acl = $acl;
$this->settings = $settingsRepo->readSettings();
$this->adapter_manager = $adapter_manager;
$this->dispatcher = $dispatcher;
}
public function indexAction(
ServerRequest $request,
Response $response,
CacheInterface $cache
): ResponseInterface {
$view = $request->getView();
$user = $request->getUser();
$router = $request->getRouter();
$show_admin = $this->acl->userAllowed($user, Acl::GLOBAL_VIEW);
/** @var Entity\Station[] $stations */
$stations = $this->em->getRepository(Entity\Station::class)->findAll();
// Don't show stations the user can't manage.
$stations = array_filter(
$stations,
function ($station) use ($user) {
/** @var Entity\Station $station */
return $station->isEnabled() &&
$this->acl->userAllowed($user, Acl::STATION_VIEW, $station->getId());
}
);
if (empty($stations) && !$show_admin) {
return $view->renderToResponse($response, 'frontend/index/noaccess');
}
// Get administrator notifications.
$notification_event = new Event\GetNotifications($request);
$this->dispatcher->dispatch($notification_event);
$notifications = $notification_event->getNotifications();
$view_stations = [];
$station_ids = [];
// Generate initial data for station dashboard view.
foreach ($stations as $row) {
$frontend_adapter = $this->adapter_manager->getFrontendAdapter($row);
$np = [
'now_playing' => [
'song' => [
'title' => '',
'artist' => '',
],
],
'listeners' => [
'current' => 0,
],
];
$station_np = $row->getNowplaying();
if ($station_np instanceof Entity\Api\NowPlaying) {
$np['now_playing']['song']['title'] = $station_np->now_playing->song->title;
$np['now_playing']['song']['artist'] = $station_np->now_playing->song->artist;
$np['listeners']['current'] = $station_np->listeners->current;
}
$view_stations[$row->getId()] = [
'station' => [
'id' => $row->getId(),
'name' => $row->getName(),
'short_name' => $row->getShortName(),
],
'public_url' => (string)$router->named('public:index', ['station_id' => $row->getShortName()]),
'manage_url' => (string)$router->named('stations:index:index', ['station_id' => $row->getId()]),
'stream_url' => (string)$frontend_adapter->getStreamUrl($row),
'np' => $np,
];
$station_ids[] = $row->getId();
}
// Detect current analytics level.
$analytics_level = $this->settings->getAnalytics();
if ($analytics_level === Entity\Analytics::LEVEL_NONE) {
$metrics = null;
} else {
// Generate unique cache ID for stations.
$stats_cache_stations = [];
foreach ($stations as $station) {
$stats_cache_stations[$station->getId()] = $station->getId();
}
$cache_name = 'homepage_metrics_' . implode(',', $stats_cache_stations);
$metrics = $cache->get(
$cache_name,
function (CacheItem $item) use ($view_stations, $show_admin) {
$item->expiresAfter(600);
return $this->getMetrics($view_stations, $show_admin);
}
);
}
return $view->renderToResponse(
$response,
'frontend/index/index',
[
'stations' => ['stations' => $view_stations],
'station_ids' => $station_ids,
'show_admin' => $show_admin,
'metrics' => $metrics,
'notifications' => $notifications,
]
);
}
/**
* @param array $stationsToView
* @param bool $showAdmin
*
* @return mixed[]
*/
protected function getMetrics(array $stationsToView, bool $showAdmin = false): array
{
// Statistics by day.
$threshold = CarbonImmutable::parse('-180 days');
$stats = $this->em->createQuery(
<<<'DQL'
SELECT a.station_id, a.moment, a.number_avg, a.number_unique
FROM App\Entity\Analytics a
WHERE a.station_id IN (:stations)
AND a.type = :type
AND a.moment >= :threshold
DQL
)->setParameter('stations', $stationsToView)
->setParameter('type', Entity\Analytics::INTERVAL_DAILY)
->setParameter('threshold', $threshold)
->getArrayResult();
$stationStats = [
'average' => [],
'unique' => [],
];
$showAllStations = $showAdmin && count($stationsToView) > 1;
foreach ($stats as $row) {
$stationId = $row['station_id'];
/** @var CarbonImmutable $moment */
$moment = $row['moment'];
$sortableKey = $moment->format('Y-m-d');
$jsTimestamp = $moment->getTimestamp() * 1000;
$average = round($row['number_avg'], 2);
$unique = $row['number_unique'];
$stationStats['average'][$stationId][$sortableKey] = [
$jsTimestamp,
$average,
];
if (null !== $unique) {
$stationStats['unique'][$stationId][$sortableKey] = [
$jsTimestamp,
$unique,
];
}
if ($showAllStations) {
if (!isset($stationStats['average']['all'][$sortableKey])) {
$stationStats['average']['all'][$sortableKey] = [
$jsTimestamp,
0,
];
}
$stationStats['average']['all'][$sortableKey][1] += $average;
if (null !== $unique) {
if (!isset($stationStats['unique']['all'][$sortableKey])) {
$stationStats['unique']['all'][$sortableKey] = [
$jsTimestamp,
0,
];
}
$stationStats['unique']['all'][$sortableKey][1] += $unique;
}
}
}
$stationsInMetric = [];
if ($showAllStations) {
$stationsInMetric['all'] = __('All Stations');
}
foreach ($stationsToView as $stationId => $station_info) {
$stationsInMetric[$stationId] = $station_info['station']['name'];
}
$jsStats = [
'average' => [
'metrics' => [],
'alt' => '',
],
'unique' => [
'metrics' => [],
'alt' => '',
],
];
foreach ($stationsInMetric as $stationId => $stationName) {
foreach ($stationStats as $statKey => $statRows) {
if (!isset($statRows[$stationId])) {
continue;
}
$series = [
'label' => $stationName,
'type' => 'line',
'fill' => false,
'data' => [],
];
$jsStats[$statKey]['alt'] .= '<p>' . $stationName . '</p>';
$jsStats[$statKey]['alt'] .= '<dl>';
ksort($statRows[$stationId]);
foreach ($statRows[$stationId] as $sortableKey => [$jsTimestamp, $value]) {
$series['data'][] = [
't' => $jsTimestamp,
'y' => $value,
];
$jsStats[$statKey]['alt'] .= sprintf(
'<dt><time data-original="%s">%s</time></dt>',
$jsTimestamp,
$sortableKey
);
$jsStats[$statKey]['alt'] .= '<dd>' . $value . ' ' . __('Listeners') . '</dd>';
}
$jsStats[$statKey]['alt'] .= '</dl>';
$jsStats[$statKey]['metrics'][] = $series;
}
}
return $jsStats;
}
}

View File

@ -7,9 +7,9 @@ use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class IndexController
class IndexAction
{
public function indexAction(
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\SettingsRepository $settingsRepo

View File

@ -0,0 +1,12 @@
<?php
namespace App\Entity\Api;
use App\Entity\Api\Traits\HasLinks;
use App\Traits\LoadFromParentObject;
class Dashboard extends NowPlaying
{
use LoadFromParentObject;
use HasLinks;
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Entity\Api;
class Notification
{
public string $title;
public string $body;
public string $type;
public ?string $actionLabel;
public ?string $actionUrl;
}

View File

@ -2,8 +2,8 @@
namespace App\Event;
use App\Entity\Api\Notification;
use App\Http\ServerRequest;
use App\Notification\Notification;
use Symfony\Contracts\EventDispatcher\Event;
class GetNotifications extends Event

View File

@ -3,9 +3,10 @@
namespace App\Notification\Check;
use App\Acl;
use App\Entity\Api\Notification;
use App\Environment;
use App\Event\GetNotifications;
use App\Notification\Notification;
use App\Session\Flash;
use App\Version;
class ComposeVersionCheck
@ -31,16 +32,29 @@ class ComposeVersionCheck
}
if (!$this->environment->isDockerRevisionAtLeast(Version::LATEST_COMPOSE_REVISION)) {
$event->addNotification(new Notification(
__('Your <code>docker-compose.yml</code> file is out of date!'),
// phpcs:disable Generic.Files.LineLength
__(
'You should update your <code>docker-compose.yml</code> file to reflect the newest changes. View the <a href="%s" target="_blank">latest version of the file</a> and update your file accordingly.<br>You can also use the <code>./docker.sh</code> utility script to automatically update your file.',
'https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/docker-compose.sample.yml'
),
// phpcs:enable
Notification::WARNING
));
// phpcs:disable Generic.Files.LineLength
$notificationBodyParts = [];
$notificationBodyParts[] = __(
'You should update your <code>docker-compose.yml</code> file to reflect the newest changes.'
);
$notificationBodyParts[] = __(
'If you manually maintain this file, review the <a href="%s" target="_blank">latest version of the file</a> and make any changes needed.',
Version::LATEST_COMPOSE_URL
);
$notificationBodyParts[] = __(
'Otherwise, update your installation and answer "Y" when prompted to update the file.',
);
// phpcs:enable Generic.Files.LineLength
$notification = new Notification();
$notification->title = __('Your <code>docker-compose.yml</code> file is out of date!');
$notification->body = implode(' ', $notificationBodyParts);
$notification->type = Flash::WARNING;
$notification->actionLabel = __('Update Instructions');
$notification->actionUrl = Version::UPDATE_URL;
$event->addNotification($notification);
}
}
}

View File

@ -6,7 +6,7 @@ use App\Acl;
use App\Entity;
use App\Environment;
use App\Event\GetNotifications;
use App\Notification\Notification;
use App\Session\Flash;
use Carbon\CarbonImmutable;
class RecentBackupCheck
@ -49,21 +49,16 @@ class RecentBackupCheck
$backupLastRun = $settings->getBackupLastRun();
if ($backupLastRun < $threshold) {
$router = $request->getRouter();
$backupUrl = $router->named('admin:backups:index');
$notification = new Entity\Api\Notification();
$notification->title = __('Installation Not Recently Backed Up');
$notification->body = __('This installation has not been backed up in the last two weeks.');
$notification->type = Flash::INFO;
$event->addNotification(
new Notification(
__('Installation Not Recently Backed Up'),
// phpcs:disable Generic.Files.LineLength
__(
'This installation has not been backed up in the last two weeks. Visit the <a href="%s" target="_blank">Backups</a> page to run a new backup.',
$backupUrl
),
// phpcs:enable
Notification::INFO
)
);
$router = $request->getRouter();
$notification->actionLabel = __('Backups');
$notification->actionUrl = (string)$router->named('admin:backups:index');
$event->addNotification($notification);
}
}
}

View File

@ -5,7 +5,7 @@ namespace App\Notification\Check;
use App\Acl;
use App\Entity;
use App\Event\GetNotifications;
use App\Notification\Notification;
use App\Session\Flash;
use App\Sync\Runner;
class SyncTaskCheck
@ -33,7 +33,7 @@ class SyncTaskCheck
$settings = $this->settingsRepo->readSettings();
$setupComplete = $settings->isSetupComplete();
$setupComplete = $settings->getSetupCompleteTime();
$syncTasks = $this->syncRunner->getSyncTimes();
foreach ($syncTasks as $taskKey => $task) {
@ -47,22 +47,22 @@ class SyncTaskCheck
}
if ($diff > ($interval * 5)) {
$router = $request->getRouter();
$backupUrl = $router->named('admin:debug:sync', ['type' => $taskKey]);
$event->addNotification(
new Notification(
__('Synchronized Task Not Recently Run'),
// phpcs:disable Generic.Files.LineLength
__(
'The "%s" synchronization task has not run recently. This may indicate an error with your installation. <a href="%s" target="_blank">Manually run the task</a> to check for errors.',
$task['name'],
$backupUrl
),
// phpcs:enable
Notification::ERROR
)
// phpcs:disable Generic.Files.LineLength
$notification = new Entity\Api\Notification();
$notification->title = __('Synchronized Task Not Recently Run');
$notification->body = __(
'The "%s" synchronization task has not run recently. This may indicate an error with your installation.',
$task['name']
);
$notification->type = Flash::ERROR;
$router = $request->getRouter();
$notification->actionLabel = __('Manually Run Task');
$notification->actionUrl = (string)$router->named('admin:debug:sync', ['type' => $taskKey]);
// phpcs:enable
$event->addNotification($notification);
}
}
}

View File

@ -5,7 +5,7 @@ namespace App\Notification\Check;
use App\Acl;
use App\Entity;
use App\Event\GetNotifications;
use App\Notification\Notification;
use App\Session\Flash;
use App\Version;
class UpdateCheck
@ -41,65 +41,57 @@ class UpdateCheck
return;
}
$instructions_url = 'https://www.azuracast.com/administration/system/updating.html';
$instructions_string = __(
'Follow the <a href="%s" target="_blank">update instructions</a> to update your installation.',
$instructions_url
);
$actionLabel = __('Update Instructions');
$actionUrl = Version::UPDATE_URL;
$releaseChannel = $this->version->getReleaseChannel();
if (Version::RELEASE_CHANNEL_STABLE === $releaseChannel && $updateData['needs_release_update']) {
$notification_parts = [
$notificationParts = [
'<b>' . __(
'AzuraCast <a href="%s" target="_blank">version %s</a> is now available.',
'https://github.com/AzuraCast/AzuraCast/releases',
Version::CHANGELOG_URL,
$updateData['latest_release']
) . '</b>',
__(
'You are currently running version %s. Updating is highly recommended.',
$updateData['current_release']
),
$instructions_string,
];
$event->addNotification(
new Notification(
__('New AzuraCast Release Version Available'),
implode(' ', $notification_parts),
Notification::INFO
)
);
$notification = new Entity\Api\Notification();
$notification->title = __('New AzuraCast Release Version Available');
$notification->body = implode(' ', $notificationParts);
$notification->type = Flash::INFO;
$notification->actionLabel = $actionLabel;
$notification->actionUrl = $actionUrl;
$event->addNotification($notification);
return;
}
if (Version::RELEASE_CHANNEL_ROLLING === $releaseChannel && $updateData['needs_rolling_update']) {
$notification_parts = [];
if ($updateData['rolling_updates_available'] < 15 && !empty($updateData['rolling_updates_list'])) {
$notification_parts[] = __('The following improvements have been made since your last update:');
$notification_parts[] = nl2br(
'<ul><li>' . implode(
'</li><li>',
$updateData['rolling_updates_list']
) . '</li></ul>'
);
} else {
$notification_parts[] = '<b>' . __(
'Your installation is currently %d update(s) behind the latest version.',
$updateData['rolling_updates_available']
) . '</b>';
$notification_parts[] = __('You should update to take advantage of bug and security fixes.');
}
$notificationParts = [];
$notification_parts[] = $instructions_string;
$notificationParts[] = '<b>' . __(
'Your installation is currently %d update(s) behind the latest version.',
$updateData['rolling_updates_available']
) . '</b>';
$event->addNotification(
new Notification(
__('New AzuraCast Updates Available'),
implode(' ', $notification_parts),
Notification::INFO
)
$notificationParts[] = sprintf(
'<a href="%s" target="_blank">' . __('View the changelog for full details.') . '</a>',
Version::CHANGELOG_URL
);
$notificationParts[] = __('You should update to take advantage of bug and security fixes.');
$notification = new Entity\Api\Notification();
$notification->title = __('New AzuraCast Updates Available');
$notification->body = implode(' ', $notificationParts);
$notification->type = Flash::INFO;
$notification->actionLabel = $actionLabel;
$notification->actionUrl = $actionUrl;
$event->addNotification($notification);
return;
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Notification;
use App\Session\Flash;
class Notification
{
// Alert type constants.
public const SUCCESS = Flash::SUCCESS;
public const WARNING = Flash::WARNING;
public const ERROR = Flash::ERROR;
public const INFO = Flash::INFO;
protected string $title;
protected string $body;
protected string $type;
public function __construct(string $title, string $body, string $type)
{
$this->title = $title;
$this->body = $body;
$this->type = $type;
}
public function getTitle(): string
{
return $this->title;
}
public function getBody(): string
{
return $this->body;
}
public function getType(): string
{
return $this->type;
}
}

View File

@ -18,7 +18,13 @@ class Version
public const RELEASE_CHANNEL_ROLLING = 'rolling';
public const RELEASE_CHANNEL_STABLE = 'stable';
// phpcs:disable Generic.Files.LineLength
public const LATEST_COMPOSE_REVISION = 11;
public const LATEST_COMPOSE_URL = 'https://raw.githubusercontent.com/AzuraCast/AzuraCast/master/docker-compose.sample.yml';
public const UPDATE_URL = 'https://www.azuracast.com/administration/system/updating.html';
public const CHANGELOG_URL = 'https://github.com/AzuraCast/AzuraCast/blob/master/CHANGELOG.md';
// phpcs:enable
protected CacheInterface $cache;

View File

@ -49,6 +49,7 @@ class View extends Engine
'acl' => $request->getAttribute(ServerRequest::ATTR_ACL),
'customization' => $request->getAttribute(ServerRequest::ATTR_CUSTOMIZATION),
'flash' => $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH),
'user' => $request->getAttribute(ServerRequest::ATTR_USER),
'assets' => $this->assets,
]
);

View File

@ -1,37 +1,31 @@
<?php
$station_ids = [];
foreach ($stations['stations'] as $station) {
$station_ids[] = urlencode($station['station']['short_name']);
}
/**
* @var \App\Entity\User $user
* @var \App\Http\Router $router
*/
$props = [
'userName' => $user->getName() ?? __('AzuraCast User'),
'userEmail' => $user->getEmail(),
'userAvatar' => $user->getAvatar(64),
'profileUrl' => (string)$router->named('profile:index'),
'adminUrl' => (string)$router->named('admin:index:index'),
'showAdmin' => $showAdmin,
'notificationsUrl' => (string)$router->named('api:frontend:dashboard:notifications'),
'showCharts' => $showCharts,
'chartsUrl' => (string)$router->named('api:frontend:dashboard:charts'),
'addStationUrl' => (string)$router->named('admin:stations:add'),
'stationsUrl' => (string)$router->named('api:frontend:dashboard:stations'),
];
?>
var station_dashboard;
$(function () {
station_dashboard = new Vue({
el: '#station_dashboard',
data: <?=json_encode($stations) ?>,
methods: {
toggle: function (url) {
this.$eventHub.$emit('player_toggle', url);
}
}
});
});
$(function () {
setTimeout(loadNowPlaying, 15000);
});
function loadNowPlaying () {
$.getJSON('<?=$router->named('api:nowplaying:index') ?>', function (data) {
$.each(data, function (k, row) {
var station_id = row.station.id;
station_dashboard['stations'][station_id]['np'] = row;
let dashboard = new Vue({
el: '#dashboard',
render: function (createElement) {
return createElement(Dashboard.default, {
props: <?=json_encode($props) ?>
});
}
});
setTimeout(loadNowPlaying, 15000);
}).fail(function () {
setTimeout(loadNowPlaying, 30000);
});
}
});

View File

@ -12,185 +12,17 @@ $this->layout('main', ['title' => __('Dashboard'), 'manual' => true]);
$assets
->load('vue')
->load('InlinePlayer')
->load('Dashboard')
->addInlineJs($this->fetch('partials/radio_controls.js'), 95)
->addInlineJs($this->fetch('frontend/index/index.js', ['stations' => $stations]));
if ($metrics) {
$assets
->load('chartjs')
->addInlineJs(
$this->fetch(
'frontend/index/index_metrics.js',
[
'metrics' => $metrics,
]
)
);
}
$user = $request->getUser();
->addInlineJs(
$this->fetch(
'frontend/index/index.js',
[
'showAdmin' => $showAdmin,
'showCharts' => $showCharts,
]
)
);
?>
<section class="card mb-4" role="region">
<div class="card-header bg-primary-dark d-flex flex-wrap align-items-center">
<a class="flex-shrink-0" href="http://www.gravatar.com/" target="_blank">
<img src="<?=$user->getAvatar(64)?>" style="width: 64px; height: auto;" alt="">
</a>
<div class="flex-fill ml-3">
<h2 class="card-title mt-0">
<?php
if (!empty($user->getName())): ?>
<?=$this->e($user->getName())?>
<?php
else: ?>
<?=__('AzuraCast User')?>
<?php
endif; ?>
</h2>
<h3 class="card-subtitle">
<?=$this->e($user->getEmail())?>
</h3>
</div>
<div class="flex-md-shrink-0 mt-3 mt-md-0">
<a class="btn btn-bg" role="button" href="<?=$router->named('profile:index')?>">
<i class="material-icons" aria-hidden="true">account_circle</i>
<?=__('My Account')?>
</a>
<?php
if ($show_admin): ?>
<a class="btn btn-bg ml-2" role="button" href="<?=$router->named('admin:index:index')?>">
<i class="material-icons" aria-hidden="true">settings</i>
<?=__('Administration')?>
</a>
<?php
endif; ?>
</div>
</div>
<?php
if (!empty($notifications)): ?>
<?php
foreach ($notifications as $notification): ?>
<?php
/** @var \App\Notification\Notification $notification */ ?>
<div class="card-body alert-<?=$notification->getType()?> d-flex" role="alert">
<div class="flex-shrink-0 mt-3 mr-3">
<?php
if (\App\Notification\Notification::INFO === $notification->getType()): ?>
<i class="material-icons lg" aria-hidden="true">info</i>
<?php
else: ?>
<i class="material-icons lg" aria-hidden="true">warning</i>
<?php
endif; ?>
</div>
<div class="flex-fill">
<h4><?=$notification->getTitle()?></h4>
<p class="card-text">
<?=$notification->getBody()?>
</p>
</div>
</div>
<?php
endforeach; ?>
<?php
endif; ?>
</section>
<?php
if ($metrics): ?>
<section class="card mb-4" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=__('Listeners Per Station')?></h3>
</div>
<div class="card-body pb-0">
<ul class="nav nav-pills nav-pills-scrollable card-header-pills">
<li class="nav-item active">
<a class="nav-link active" role="tab" data-toggle="tab" aria-expanded="true" aria-controls="tab_chart_average" href="#tab_chart_average">
<?=__('Average Listeners')?>
</a>
</li>
<li class="nav-item">
<a class="nav-link" role="tab" data-toggle="tab" aria-expanded="true" aria-controls="tab_chart_unique" href="#tab_chart_unique">
<?=__('Unique Listeners')?>
</a>
</li>
</ul>
</div>
<div class="tab-content">
<div class="card-body tab-pane active" id="tab_chart_average">
<canvas id="chart_average" style="width: 100%;" aria-label="<?=__('Average Listeners')?>" role="img">
<?=$metrics['average']['alt']?>
</canvas>
</div>
<div class="card-body tab-pane" id="tab_chart_unique">
<canvas id="chart_unique" style="width: 100%;" aria-label="<?=__('Unique Listeners')?>" role="img">
<?=$metrics['unique']['alt']?>
</canvas>
</div>
</div>
</section>
<?php
endif; ?>
<section class="card" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=__('Station Overview')?></h3>
</div>
<?php
if ($acl->userAllowed($user, 'administer stations')): ?>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->named('admin:stations:add')?>">
<i class="material-icons" aria-hidden="true">add</i>
<?=__('Add Station')?>
</a>
</div>
<?php
endif; ?>
<table class="table table-striped table-responsive mb-0" id="station_dashboard">
<colgroup>
<col width="5%">
<col width="30%">
<col width="10%">
<col width="40%">
<col width="15%">
</colgroup>
<thead>
<tr>
<th class="pr-3">&nbsp;</th>
<th class="pl-2"><?=__('Station Name')?></th>
<th class="text-center"><?=__('Listeners')?></th>
<th><?=__('Now Playing')?></th>
<th class="text-right"></th>
</tr>
</thead>
<tbody>
<tr class="align-middle" v-bind:id="'station_' + row.station.id" v-for="row in stations">
<td class="text-center pr-3">
<a class="btn-audio" href="#" v-bind:data-url="row.stream_url" @click.prevent="toggle(row.stream_url)">
<i class="material-icons lg align-middle" aria-hidden="true">play_circle_filled</i>
</a>
</td>
<td class="pl-2">
<big><a v-bind:href="row.public_url" target="_blank">{{ row.station.name }}</a></big>
</td>
<td class="text-center">
<span class="nowplaying-listeners">{{ row.np.listeners.current }}</span>
</td>
<td>
<div v-if="row.np.now_playing.song.title != ''">
<strong><span class="nowplaying-title">{{ row.np.now_playing.song.title }}</span></strong><br>
<span class="nowplaying-artist">{{ row.np.now_playing.song.artist }}</span>
</div>
<div v-else>
<strong><span class="nowplaying-title">{{ row.np.now_playing.song.text }}</span></strong>
</div>
</td>
<td class="text-right">
<a class="btn btn-primary" v-bind:href="row.manage_url"><?=__('Manage')?></a>
</td>
</tr>
</tbody>
</table>
</section>
<div id="dashboard"></div>

View File

@ -1,69 +0,0 @@
$(function () {
Chart.platform.disableCSSInjection = true;
moment.tz.setDefault('UTC');
const chartOptions = {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
},
scales: {
xAxes: [{
type: 'time',
distribution: 'linear',
time: {
unit: 'day'
},
ticks: {
source: 'data',
autoSkip: true
}
}],
yAxes: [{
scaleLabel: {
display: true,
labelString: <?=$this->escapeJs(__('Listeners')) ?>
},
ticks: {
min: 0
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
callbacks: {
label: function (tooltipItem, myData) {
var label = myData.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += parseFloat(tooltipItem.value).toFixed(2);
return label;
}
}
}
};
let averageChart = new Chart(document.getElementById('chart_average').getContext('2d'), {
type: 'bar',
data: {
datasets: <?=json_encode($metrics['average']['metrics'], \JSON_THROW_ON_ERROR) ?>
},
options: chartOptions
});
let uniqueChart = new Chart(document.getElementById('chart_unique').getContext('2d'), {
type: 'bar',
data: {
datasets: <?=json_encode($metrics['unique']['metrics'], \JSON_THROW_ON_ERROR) ?>
},
options: chartOptions
});
$('canvas time').each(function () {
$(this).text(moment.utc($(this).data('original')).format('ll'));
});
});

View File

@ -1,5 +0,0 @@
<?php $this->layout('main', ['title' => __('Error: No Available Stations')]); ?>
<p>
<?=__('Your account is active, but is not currently associated with any stations. If you believe this is an error, please contact this server\'s administrator.') ?>
</p>

View File

@ -14,8 +14,6 @@
$page_class ??= '';
$page_class .= ' theme-' . $customization->getTheme();
$user = $request->getUser();
?>
<!DOCTYPE html>
<html>
@ -38,27 +36,37 @@ $user = $request->getUser();
echo $assets->js();
?>
</head>
<body class="page-full <?=$page_class?> <?php if (!empty($sidebar)): ?>has-sidebar<?php endif; ?>">
<body class="page-full <?=$page_class?> <?php
if (!empty($sidebar)): ?>has-sidebar<?php
endif; ?>">
<?=$assets->inlineJs()?>
<a class="sr-only sr-only-focusable" href="#content"><?=__('Skip to main content')?></a>
<header class="navbar bg-primary-dark shadow-sm fixed-top">
<?php if (!empty($sidebar)): ?>
<button aria-controls="sidebar" aria-expanded="false" aria-label="<?=__('Toggle Sidebar')?>" class="navbar-toggler d-inline-flex d-lg-none mr-3" data-breakpoint="lg" data-target="#sidebar" data-toggle="navdrawer" data-type="permanent">
<?php
if (!empty($sidebar)): ?>
<button aria-controls="sidebar" aria-expanded="false" aria-label="<?=__(
'Toggle Sidebar'
)?>" class="navbar-toggler d-inline-flex d-lg-none mr-3" data-breakpoint="lg" data-target="#sidebar" data-toggle="navdrawer" data-type="permanent">
<span class="navbar-toggler-icon"></span>
</button>
<?php endif; ?>
<?php
endif; ?>
<a class="navbar-brand ml-0 mr-auto" href="<?=$router->named('dashboard')?>">
azura<strong>cast</strong> <?php if (!empty($customization->getInstanceName())): ?>
<small><?=$this->e($customization->getInstanceName())?></small><?php endif; ?>
azura<strong>cast</strong> <?php
if (!empty($customization->getInstanceName())): ?>
<small><?=$this->e($customization->getInstanceName())?></small><?php
endif; ?>
</a>
<div id="radio-player-controls"></div>
<div class="dropdown ml-3">
<button aria-expanded="false" aria-haspopup="true" class="navbar-toggler" aria-label="<?=__('Toggle Menu')?>" data-toggle="dropdown" type="button">
<button aria-expanded="false" aria-haspopup="true" class="navbar-toggler" aria-label="<?=__(
'Toggle Menu'
)?>" data-toggle="dropdown" type="button">
<i class="material-icons" aria-hidden="true">more_vert</i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
@ -69,14 +77,16 @@ $user = $request->getUser();
</a>
</li>
<li class="dropdown-divider">&nbsp;</li>
<?php if ($acl->userAllowed($user, \App\Acl::GLOBAL_VIEW)): ?>
<?php
if ($acl->userAllowed($user, \App\Acl::GLOBAL_VIEW)): ?>
<li>
<a class="dropdown-item" href="<?=$router->named('admin:index:index')?>">
<i class="material-icons" aria-hidden="true">settings</i>
<?=__('System Administration')?>
</a>
</li>
<?php endif; ?>
<?php
endif; ?>
<li>
<a class="dropdown-item" href="<?=$router->named('profile:index')?>">
<i class="material-icons" aria-hidden="true">account_circle</i>
@ -102,42 +112,53 @@ $user = $request->getUser();
</a>
</li>
<li class="dropdown-divider">&nbsp;</li>
<?php if ($auth->isMasqueraded()): ?>
<?php
if ($auth->isMasqueraded()): ?>
<li>
<a class="dropdown-item" href="<?=$router->named('account:endmasquerade')?>">
<i class="material-icons" aria-hidden="true">exit_to_app</i>
<?=__('End Session')?>
</a>
</li>
<?php else: ?>
<?php
else: ?>
<li>
<a class="dropdown-item" href="<?=$router->named('account:logout')?>">
<i class="material-icons" aria-hidden="true">exit_to_app</i>
<?=__('Sign Out')?></a></li>
<?php endif; ?>
<?php
endif; ?>
</ul>
</div>
</header>
<?php if (!empty($sidebar)): ?>
<?php
if (!empty($sidebar)): ?>
<div class="navdrawer navdrawer-permanent-lg navdrawer-permanent-clipped" id="sidebar" tabindex="-1">
<nav class="navdrawer-content">
<?=$sidebar?>
</nav>
</div>
<?php endif; ?>
<?php
endif; ?>
<section id="main">
<section id="content" <?php if (empty($sidebar)): ?>class="content-alt"<?php endif; ?> role="main">
<section id="content" <?php
if (empty($sidebar)): ?>class="content-alt"<?php
endif; ?> role="main">
<div class="container">
<?php if ($manual): ?>
<?php
if ($manual): ?>
<?=$this->section('content')?>
<?php else: ?>
<?php if ($header): ?>
<?php
else: ?>
<?php
if ($header): ?>
<div class="block-header">
<h2><?=$header?></h2>
</div>
<?php endif; ?>
<?php
endif; ?>
<div class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title"><?=$title?></h3>
@ -146,30 +167,44 @@ $user = $request->getUser();
<?=$this->section('content')?>
</div>
</div>
<?php endif; ?>
<?php
endif; ?>
</div>
</section>
</section>
<footer id="footer" <?php if (empty($sidebar)): ?>class="footer-alt"<?php endif; ?> role="contentinfo">
<?=__('Powered by %s',
'<a href="https://www.azuracast.com/" target="_blank">' . $environment->getAppName() . '</a> &bull; ' . $version->getVersionText() . ' &bull; ' . ($environment->isDocker() ? 'Docker' : 'Ansible') . ' &bull; PHP ' . \PHP_MAJOR_VERSION . '.' . \PHP_MINOR_VERSION)?>
<footer id="footer" <?php
if (empty($sidebar)): ?>class="footer-alt"<?php
endif; ?> role="contentinfo">
<?=__(
'Powered by %s',
'<a href="https://www.azuracast.com/" target="_blank">' . $environment->getAppName(
) . '</a> &bull; ' . $version->getVersionText() . ' &bull; ' . ($environment->isDocker(
) ? 'Docker' : 'Ansible') . ' &bull; PHP ' . \PHP_MAJOR_VERSION . '.' . \PHP_MINOR_VERSION
)?>
<br>
<?=__('Like our software? <a href="%s" target="_blank">Donate to support AzuraCast!</a>',
'https://www.azuracast.com/donate.html')?>
<?=__(
'Like our software? <a href="%s" target="_blank">Donate to support AzuraCast!</a>',
'https://www.azuracast.com/donate.html'
)?>
</footer>
<div id="radio-player"></div>
<?php if (null !== $flash && $flash->hasMessages()): ?>
<?php
if (null !== $flash && $flash->hasMessages()): ?>
<script type="text/javascript" nonce="<?=$assets->getCspNonce()?>">
$(function () {
<?php foreach($flash->getMessages() as $message): ?>
notify("<?=str_replace(['"', "\n"], ['\'', '<br>'],
$message['text']) ?>", '<?=$message['color'] ?>', false);
notify("<?=str_replace(
['"', "\n"],
['\'', '<br>'],
$message['text']
) ?>", '<?=$message['color'] ?>', false);
<?php endforeach; ?>
});
</script>
<?php endif; ?>
<?php
endif; ?>
</body>
</html>

View File

@ -1,5 +1,7 @@
<?php
/** @var \App\Customization $customization */
$assets->load('InlinePlayer');
?>
var inline_player;

View File

@ -1,23 +1,28 @@
<?php
/** @var \App\Assets $assets */
$this->layout('main', [
'title' => __('Music Files'),
'manual' => true,
'page_class' => 'page-file-manager',
]);
$this->layout(
'main',
[
'title' => __('Music Files'),
'manual' => true,
'page_class' => 'page-file-manager',
]
);
$assets
->load('vue')
->load('moment')
->load('fancybox')
->load('InlinePlayer')
->load('StationMedia')
->addInlineJs($this->fetch('partials/radio_controls.js'), 95)
->addInlineJs($this->fetch('stations/files/index.js', [
'playlists' => $playlists,
'custom_fields' => $custom_fields,
'mime_types' => $mime_types,
]));
->addInlineJs(
$this->fetch(
'stations/files/index.js',
[
'playlists' => $playlists,
'custom_fields' => $custom_fields,
'mime_types' => $mime_types,
]
)
);
?>
<div class="row">
@ -29,7 +34,8 @@ $assets
<h2 class="card-title"><?=__('Music Files')?></h2>
</div>
<div class="col-md-5 text-right text-white-50">
<?php if ($space_total): ?>
<?php
if ($space_total): ?>
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="<?=$space_percent?>"
aria-valuemin="0" aria-valuemax="100" style="width: <?=$space_percent?>%;">
@ -37,13 +43,16 @@ $assets
</div>
</div>
<?=__('%s of %s Used (%d Files)', $space_used, $space_total, $files_count)?>
<?php else: ?>
<?php
else: ?>
<?=__('%s Used (%d Files)', $space_used, $files_count)?>
<?php endif; ?>
<?php
endif; ?>
</div>
</div>
</div>
<?php if ($show_sftp): ?>
<?php
if ($show_sftp): ?>
<div class="card-body alert-info d-flex align-items-center" role="alert">
<div class="flex-shrink-0 mr-2">
<i class="material-icons" aria-hidden="true">info</i>
@ -60,7 +69,8 @@ $assets
</a>
</div>
</div>
<?php endif; ?>
<?php
endif; ?>
<div id="media-manager"></div>
</div>

View File

@ -4,15 +4,17 @@
* @var \App\Assets $assets
*/
$this->layout('main', [
'title' => __('Profile'),
'manual' => true,
'sidebar_tab' => 'profile',
]);
$this->layout(
'main',
[
'title' => __('Profile'),
'manual' => true,
'sidebar_tab' => 'profile',
]
);
$assets
->load('fancybox')
->load('InlinePlayer')
->load('StationProfile')
->addInlineJs($this->fetch('partials/radio_controls.js'), 95)
->addInlineJs($this->fetch('stations/profile/index.js'), 99);

View File

@ -11,8 +11,6 @@ class A02_Frontend_IndexCest extends CestAbstract
$I->wantTo('See the proper data on the homepage.');
$I->amOnPage('/dashboard');
$I->see('Dashboard');
$I->see('Listeners Per Station');
$I->seeInTitle('Dashboard');
}
}