Dashboard Overhaul (#3651)
This commit is contained in:
parent
6b53bc4cea
commit
7862c6d515
|
@ -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
|
||||
],
|
||||
];
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
||||
</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>
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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']
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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"> </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>
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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"> </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"> </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> • ' . $version->getVersionText() . ' • ' . ($environment->isDocker() ? 'Docker' : 'Ansible') . ' • 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> • ' . $version->getVersionText() . ' • ' . ($environment->isDocker(
|
||||
) ? 'Docker' : 'Ansible') . ' • 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>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
/** @var \App\Customization $customization */
|
||||
|
||||
$assets->load('InlinePlayer');
|
||||
?>
|
||||
|
||||
var inline_player;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue