Add more stats; abstract common metrics view.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-06-15 07:13:34 -05:00
parent 945c9dc2a5
commit c840bb084e
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
14 changed files with 492 additions and 307 deletions

View File

@ -522,6 +522,16 @@ return static function (RouteCollectorProxy $group) {
Controller\Api\Stations\Reports\Overview\ByStream::class
)->setName('api:stations:reports:by-stream');
$group->get(
'/overview/by-client',
Controller\Api\Stations\Reports\Overview\ByClient::class
)->setName('api:stations:reports:by-client');
$group->get(
'/overview/by-listening-time',
Controller\Api\Stations\Reports\Overview\ByListeningTime::class
)->setName('api:stations:reports:by-listening-time');
$group->get(
'/soundexchange',
Controller\Api\Stations\Reports\SoundExchangeAction::class

View File

@ -30,6 +30,15 @@
</listeners-by-time-period-tab>
</b-tab>
<b-tab>
<template #title>
<translate key="tab_listening_time">Listening Time</translate>
</template>
<listening-time-tab :api-url="listeningTimeUrl" :date-range="dateRange">
</listening-time-tab>
</b-tab>
<b-tab>
<template #title>
<translate key="tab_streams">Streams</translate>
@ -39,6 +48,15 @@
</streams-tab>
</b-tab>
<b-tab v-if="showFullAnalytics">
<template #title>
<translate key="tab_clients">Clients</translate>
</template>
<clients-tab :api-url="byClientUrl" :date-range="dateRange">
</clients-tab>
</b-tab>
<b-tab v-if="showFullAnalytics">
<template #title>
<translate key="tab_browsers">Browsers</translate>
@ -66,11 +84,15 @@ import DateRangeDropdown from "~/components/Common/DateRangeDropdown";
import ListenersByTimePeriodTab from "./Overview/ListenersByTimePeriodTab";
import BestAndWorstTab from "./Overview/BestAndWorstTab";
import BrowsersTab from "./Overview/BrowsersTab";
import CountriesTab from "~/components/Stations/Reports/Overview/CountriesTab";
import StreamsTab from "~/components/Stations/Reports/Overview/StreamsTab";
import CountriesTab from "./Overview/CountriesTab";
import StreamsTab from "./Overview/StreamsTab";
import ClientsTab from "./Overview/ClientsTab";
import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab";
export default {
components: {
ListeningTimeTab,
ClientsTab,
StreamsTab,
CountriesTab,
BrowsersTab,
@ -84,8 +106,10 @@ export default {
listenersByTimePeriodUrl: String,
bestAndWorstUrl: String,
byStreamUrl: String,
byClientUrl: String,
byBrowserUrl: String,
byCountryUrl: String,
listeningTimeUrl: String
},
data() {
let nowTz = DateTime.now().setZone(this.stationTimeZone);

View File

@ -1,114 +1,28 @@
<template>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_top_by_listeners">Top Browsers by Listeners</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate
key="hdr_top_by_connected_seconds">Top Browsers by Connected Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
<common-metrics-view :date-range="dateRange" :api-url="apiUrl"
field-key="browser" :field-label="langFieldLabel">
<template #by_listeners_legend>
<translate key="hdr_top_by_listeners">Top Browsers by Listeners</translate>
</template>
<template #by_connected_time_legend>
<translate key="hdr_top_by_connected_seconds">Top Browsers by Connected Time</translate>
</template>
</common-metrics-view>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
import IsMounted from "~/components/Common/IsMounted";
import CommonMetricsView from "./CommonMetricsView";
export default {
name: 'BrowsersTab',
components: {DataTable, PieChart},
mixins: [IsMounted],
components: {CommonMetricsView},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'browser', label: this.$gettext('Browser'), sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
if (this.isMounted) {
this.relist();
}
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
computed: {
langFieldLabel() {
return this.$gettext('Browser');
}
}
}

View File

@ -0,0 +1,29 @@
<template>
<common-metrics-view :date-range="dateRange" :api-url="apiUrl"
field-key="client" :field-label="langFieldLabel">
<template #by_listeners_legend>
<translate key="hdr_top_by_listeners">Clients by Listeners</translate>
</template>
<template #by_connected_time_legend>
<translate key="hdr_top_by_connected_seconds">Clients by Connected Time</translate>
</template>
</common-metrics-view>
</template>
<script>
import CommonMetricsView from "./CommonMetricsView";
export default {
name: 'ClientsTab',
components: {CommonMetricsView},
props: {
dateRange: Object,
apiUrl: String,
},
computed: {
langFieldLabel() {
return this.$gettext('Client');
}
}
}
</script>

View File

@ -0,0 +1,116 @@
<template>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<slot name="by_listeners_legend"></slot>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<slot name="by_connected_time_legend"></slot>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" :id="fieldKey+'_table'" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
import IsMounted from "~/components/Common/IsMounted";
export default {
name: 'CommonMetricsView',
components: {DataTable, PieChart},
mixins: [IsMounted],
props: {
dateRange: Object,
apiUrl: String,
fieldKey: String,
fieldLabel: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: this.fieldKey, label: this.fieldLabel, sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
if (this.isMounted) {
this.relist();
}
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
}
}
}
</script>

View File

@ -1,114 +1,28 @@
<template>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_top_by_listeners">Top Countries by Listeners</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate
key="hdr_top_by_connected_seconds">Top Countries by Connected Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
<common-metrics-view :date-range="dateRange" :api-url="apiUrl"
field-key="country" :field-label="langFieldLabel">
<template #by_listeners_legend>
<translate key="hdr_top_by_listeners">Top Countries by Listeners</translate>
</template>
<template #by_connected_time_legend>
<translate key="hdr_top_by_connected_seconds">Top Countries by Connected Time</translate>
</template>
</common-metrics-view>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
import IsMounted from "~/components/Common/IsMounted";
import CommonMetricsView from "./CommonMetricsView";
export default {
name: 'CountriesTab',
components: {DataTable, PieChart},
mixins: [IsMounted],
components: {CommonMetricsView},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'country', label: this.$gettext('Country'), sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
if (this.isMounted) {
this.relist();
}
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
computed: {
langFieldLabel() {
return this.$gettext('Country');
}
}
}

View File

@ -0,0 +1,83 @@
<template>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<fieldset>
<legend>
<translate key="chart_listening_time">Listeners by Listening Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="chart.datasets"
:labels="chart.labels" :aspect-ratio="4">
<span v-html="chart.alt"></span>
</pie-chart>
</fieldset>
</div>
<data-table ref="datatable" id="listening_time_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
</data-table>
</div>
</b-overlay>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import DataTable from "~/components/Common/DataTable";
import IsMounted from "~/components/Common/IsMounted";
export default {
name: 'ListeningTimeTab',
components: {DataTable, PieChart},
mixins: [IsMounted],
props: {
dateRange: Object,
apiUrl: String
},
data() {
return {
loading: true,
all: [],
chart: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'label', label: this.$gettext('Listening Time'), sortable: false},
{key: 'value', label: this.$gettext('Listeners'), sortable: false}
]
};
},
watch: {
dateRange() {
if (this.isMounted) {
this.relist();
}
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.chart = response.data.chart;
this.loading = false;
});
}
}
}
</script>

View File

@ -1,114 +1,28 @@
<template>
<b-overlay variant="card" :show="loading">
<div class="card-body py-5" v-if="loading">
&nbsp;
</div>
<div v-else>
<div class="card-body">
<b-row>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate key="hdr_top_by_listeners">Top Streams by Listeners</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
:labels="top_listeners.labels">
<span v-html="top_listeners.alt"></span>
</pie-chart>
</fieldset>
</b-col>
<b-col md="6" class="mb-4">
<fieldset>
<legend>
<translate
key="hdr_top_by_connected_seconds">Top Streams by Connected Time</translate>
</legend>
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
:labels="top_connected_time.labels">
<span v-html="top_connected_time.alt"></span>
</pie-chart>
</fieldset>
</b-col>
</b-row>
</div>
<data-table ref="datatable" id="streams_table" paginated handle-client-side
:fields="fields" :responsive="false" :items="all">
<template #cell(connected_seconds_calc)="row">
{{ formatTime(row.item.connected_seconds) }}
</template>
</data-table>
</div>
</b-overlay>
<common-metrics-view :date-range="dateRange" :api-url="apiUrl"
field-key="stream" :field-label="langFieldLabel">
<template #by_listeners_legend>
<translate key="hdr_top_by_listeners">Top Streams by Listeners</translate>
</template>
<template #by_connected_time_legend>
<translate key="hdr_top_by_connected_seconds">Top Streams by Connected Time</translate>
</template>
</common-metrics-view>
</template>
<script>
import {DateTime} from "luxon";
import PieChart from "~/components/Common/PieChart";
import formatTime from "~/functions/formatTime";
import DataTable from "~/components/Common/DataTable";
import IsMounted from "~/components/Common/IsMounted";
import CommonMetricsView from "./CommonMetricsView";
export default {
name: 'StreamsTab',
components: {DataTable, PieChart},
mixins: [IsMounted],
components: {CommonMetricsView},
props: {
dateRange: Object,
apiUrl: String,
},
data() {
return {
loading: true,
all: [],
top_listeners: {
labels: [],
datasets: [],
alt: ''
},
top_connected_time: {
labels: [],
datasets: [],
alt: ''
},
fields: [
{key: 'stream', label: this.$gettext('Stream'), sortable: true},
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
]
};
},
watch: {
dateRange() {
if (this.isMounted) {
this.relist();
}
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.loading = true;
this.axios.get(this.apiUrl, {
params: {
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
}
}).then((response) => {
this.all = response.data.all;
this.top_listeners = response.data.top_listeners;
this.top_connected_time = response.data.top_connected_time;
this.loading = false;
});
},
formatTime(time) {
return formatTime(time);
computed: {
langFieldLabel() {
return this.$gettext('Stream');
}
}
}

View File

@ -21,7 +21,7 @@ abstract class AbstractReportAction
protected function isAllAnalyticsEnabled(): bool
{
return AnalyticsLevel::All !== $this->settingsRepo->readSettings()->getAnalyticsEnum();
return AnalyticsLevel::All === $this->settingsRepo->readSettings()->getAnalyticsEnum();
}
protected function isAnalyticsEnabled(): bool
@ -31,10 +31,14 @@ abstract class AbstractReportAction
protected function buildChart(
array $rows,
string $valueLabel
string $valueLabel,
?int $limitResults = 10
): array {
arsort($rows);
$topRows = array_slice($rows, 0, 10);
$topRows = (null !== $limitResults)
? array_slice($rows, 0, $limitResults)
: $rows;
$alt = ['<dl>'];
$labels = [];

View File

@ -17,7 +17,7 @@ final class ByBrowser extends AbstractReportAction
string $station_id
): ResponseInterface {
// Get current analytics level.
if ($this->isAllAnalyticsEnabled()) {
if (!$this->isAllAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class ByClient extends AbstractReportAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
// Get current analytics level.
if (!$this->isAllAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
$dateRange = $this->getDateRange($request, $stationTz);
$statsRaw = $this->em->getConnection()->fetchAllAssociative(
<<<'SQL'
SELECT l.client_raw,
COUNT(l.listener_hash) AS listeners,
SUM(l.connected_seconds) AS connected_seconds
FROM (
SELECT
CASE
WHEN device_is_bot = 1 THEN 'bot'
WHEN device_is_mobile = 1 THEN 'mobile'
WHEN device_is_browser = 1 THEN 'desktop'
ELSE 'non_browser'
END AS client_raw,
SUM(timestamp_end - timestamp_start) AS connected_seconds,
listener_hash
FROM listener
WHERE station_id = :station_id
AND timestamp_end >= :start
AND timestamp_start <= :end
GROUP BY listener_hash
) AS l
GROUP BY l.client_raw
SQL,
[
'station_id' => $station->getIdRequired(),
'start' => $dateRange->getStartTimestamp(),
'end' => $dateRange->getEndTimestamp(),
]
);
$clientTypes = [
'bot' => __('Bot/Crawler'),
'mobile' => __('Mobile Device'),
'desktop' => __('Desktop Browser'),
'non_browser' => __('Non-Browser'),
];
$listenersByClient = [];
$connectedTimeByClient = [];
$stats = [];
foreach ($statsRaw as $row) {
$row['client'] = $clientTypes[$row['client_raw']];
$stats[] = $row;
$listenersByClient[$row['client']] = $row['listeners'];
$connectedTimeByClient[$row['client']] = $row['connected_seconds'];
}
return $response->withJson([
'all' => $stats,
'top_listeners' => $this->buildChart($listenersByClient, __('Listeners'), null),
'top_connected_time' => $this->buildChart($connectedTimeByClient, __('Connected Seconds'), null),
]);
}
}

View File

@ -18,7 +18,7 @@ final class ByCountry extends AbstractReportAction
string $station_id
): ResponseInterface {
// Get current analytics level.
if ($this->isAllAnalyticsEnabled()) {
if (!$this->isAllAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class ByListeningTime extends AbstractReportAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $station_id
): ResponseInterface {
// Get current analytics level.
if (!$this->isAnalyticsEnabled()) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$station = $request->getStation();
$stationTz = $station->getTimezoneObject();
$dateRange = $this->getDateRange($request, $stationTz);
$statsRaw = $this->em->getConnection()->fetchAllAssociative(
<<<'SQL'
SELECT SUM(timestamp_end - timestamp_start) AS connected_seconds, listener_hash
FROM listener
WHERE station_id = :station_id
AND timestamp_end >= :start
AND timestamp_start <= :end
GROUP BY listener_hash
SQL,
[
'station_id' => $station->getIdRequired(),
'start' => $dateRange->getStartTimestamp(),
'end' => $dateRange->getEndTimestamp(),
]
);
$ranges = [
[30, __('Less than Thirty Seconds')],
[60, __('Thirty Seconds to One Minute')],
[300, __('One Minute to Five Minutes')],
[600, __('Five Minutes to Ten Minutes')],
[1800, __('Ten Minutes to Thirty Minutes')],
[3600, __('Thirty Minutes to One Hour')],
[7200, __('One Hour to Two Hours')],
[PHP_INT_MAX, __('More than Two Hours')],
];
$statsByRange = [];
foreach ($ranges as [$max, $label]) {
$statsByRange[$label] = 0;
}
foreach ($statsRaw as $row) {
$listenerTime = (int)$row['connected_seconds'];
foreach ($ranges as [$max, $label]) {
if ($listenerTime <= $max) {
$statsByRange[$label]++;
break;
}
}
}
$stats = [];
foreach ($statsByRange as $key => $row) {
$stats[] = [
'label' => $key,
'value' => $row,
];
}
return $response->withJson([
'all' => $stats,
'chart' => $this->buildChart(
array_filter($statsByRange),
__('Listeners'),
null
),
]);
}
}

View File

@ -45,6 +45,8 @@ final class OverviewAction
'byStreamUrl' => (string)$router->fromHere('api:stations:reports:by-stream'),
'byBrowserUrl' => (string)$router->fromHere('api:stations:reports:by-browser'),
'byCountryUrl' => (string)$router->fromHere('api:stations:reports:by-country'),
'byClientUrl' => (string)$router->fromHere('api:stations:reports:by-client'),
'listeningTimeUrl' => (string)$router->fromHere('api:stations:reports:by-listening-time'),
]
);
}