AzuraCast/frontend/src/components/Stations/Reports/Listeners.vue

408 lines
15 KiB
Vue

<template>
<div class="row">
<div class="col-sm-12">
<div class="card">
<div class="card-header text-bg-primary">
<div class="d-lg-flex align-items-center">
<div class="flex-fill my-0">
<h2 class="card-title">
{{ $gettext('Listeners') }}
</h2>
</div>
<div class="flex-shrink buttons mt-2 mt-lg-0">
<a
id="btn-export"
class="btn btn-dark"
:href="exportUrl"
target="_blank"
>
<icon :icon="IconDownload" />
<span>
{{ $gettext('Download CSV') }}
</span>
</a>
</div>
<div
v-if="!isLive"
class="flex-shrink buttons ms-lg-2 mt-2 mt-lg-0"
>
<date-range-dropdown
v-model="dateRange"
time-picker
:min-date="minDate"
:max-date="maxDate"
:tz="timezone"
/>
</div>
</div>
</div>
<div class="card-body pb-0">
<nav
class="nav nav-tabs"
role="tablist"
>
<div
class="nav-item"
role="presentation"
>
<button
class="nav-link"
:class="(isLive) ? 'active' : ''"
type="button"
role="tab"
@click="setIsLive(true)"
>
{{ $gettext('Live Listeners') }}
</button>
</div>
<div
class="nav-item"
role="presentation"
>
<button
class="nav-link"
:class="(!isLive) ? 'active' : ''"
type="button"
role="tab"
@click="setIsLive(false)"
>
{{ $gettext('Listener History') }}
</button>
</div>
</nav>
</div>
<div id="map">
<StationReportsListenersMap
:listeners="filteredListeners"
/>
</div>
<div>
<div class="card-body">
<div class="row row-cols-md-auto align-items-center">
<div class="col-12 text-start text-md-end h5">
{{ $gettext('Unique Listeners') }}
<br>
<small>
{{ $gettext('for selected period') }}
</small>
</div>
<div class="col-12 h3">
{{ listeners.length }}
</div>
<div class="col-12 text-start text-md-end h5">
{{ $gettext('Total Listener Hours') }}
<br>
<small>
{{ $gettext('for selected period') }}
</small>
</div>
<div class="col-12 h3">
{{ totalListenerHours }}
</div>
<div class="col-12">
<listener-filters-bar v-model:filters="filters" />
</div>
</div>
</div>
<data-table
id="station_listeners"
ref="$datatable"
paginated
handle-client-side
:fields="fields"
:items="filteredListeners"
select-fields
@refresh-clicked="updateListeners()"
>
<!-- eslint-disable-next-line -->
<template #cell(device.client)="row">
<div class="d-flex align-items-center">
<div class="flex-shrink-0 pe-2">
<span v-if="row.item.device.is_bot">
<icon :icon="IconRouter" />
<span class="visually-hidden">
{{ $gettext('Bot/Crawler') }}
</span>
</span>
<span v-else-if="row.item.device.is_mobile">
<icon :icon="IconSmartphone" />
<span class="visually-hidden">
{{ $gettext('Mobile') }}
</span>
</span>
<span v-else>
<icon :icon="IconDesktopWindows" />
<span class="visually-hidden">
{{ $gettext('Desktop') }}
</span>
</span>
</div>
<div class="flex-fill">
<div v-if="row.item.device.client">
{{ row.item.device.client }}
</div>
<div class="small">
{{ row.item.user_agent }}
</div>
</div>
</div>
</template>
<template #cell(stream)="row">
<span v-if="row.item.mount_name === ''">
{{ $gettext('Unknown') }}
</span>
<span v-else>
{{ row.item.mount_name }}<br>
<small v-if="row.item.mount_is_local">
{{ $gettext('Local') }}
</small>
<small v-else>
{{ $gettext('Remote') }}
</small>
</span>
</template>
<template #cell(location)="row">
<span v-if="row.item.location.description">
{{ row.item.location.description }}
</span>
<span v-else>
{{ $gettext('Unknown') }}
</span>
</template>
</data-table>
</div>
<div
class="card-body card-padding-sm text-muted"
v-html="attribution"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import StationReportsListenersMap from "./Listeners/Map.vue";
import Icon from "~/components/Common/Icon.vue";
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
import {computed, ComputedRef, nextTick, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
import {useTranslate} from "~/vendor/gettext";
import {useAxios} from "~/vendor/axios";
import {getStationApiUrl} from "~/router";
import {IconDesktopWindows, IconDownload, IconRouter, IconSmartphone} from "~/components/Common/icons";
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
import {ListenerFilters, ListenerTypeFilter} from "~/components/Stations/Reports/Listeners/listenerFilters.ts";
import {filter} from "lodash";
import formatTime from "~/functions/formatTime.ts";
import ListenerFiltersBar from "./Listeners/FiltersBar.vue";
import {ApiListener} from "~/entities/ApiInterfaces.ts";
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
import {useLuxon} from "~/vendor/luxon.ts";
const props = defineProps({
attribution: {
type: String,
required: true
}
});
const apiUrl = getStationApiUrl('/listeners');
const isLive = ref<boolean>(true);
const listeners: ShallowRef<ApiListener[]> = shallowRef([]);
const {DateTime} = useLuxon();
const {
now,
formatTimestampAsDateTime
} = useStationDateTimeFormatter();
const nowTz = now();
const minDate = nowTz.minus({years: 5}).toJSDate();
const maxDate = nowTz.plus({days: 5}).toJSDate();
const dateRange = ref({
startDate: nowTz.minus({days: 1}).toJSDate(),
endDate: nowTz.toJSDate()
});
const filters: Ref<ListenerFilters> = ref({
minLength: null,
maxLength: null,
type: ListenerTypeFilter.All,
});
const {$gettext} = useTranslate();
const fields: DataTableField[] = [
{
key: 'ip', label: $gettext('IP'), sortable: false,
selectable: true,
visible: true
},
{
key: 'connected_time',
label: $gettext('Time'),
sortable: true,
formatter: (_col, _key, item) => {
return formatTime(item.connected_time)
},
selectable: true,
visible: true
},
{
key: 'connected_time_sec',
label: $gettext('Time (sec)'),
sortable: false,
formatter: (_col, _key, item) => {
return item.connected_time;
},
selectable: true,
visible: false
},
{
key: 'connected_on',
label: $gettext('Start Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_on,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'connected_until',
label: $gettext('End Time'),
sortable: true,
formatter: (_col, _key, item) => formatTimestampAsDateTime(
item.connected_until,
DateTime.DATETIME_SHORT
),
selectable: true,
visible: false
},
{
key: 'device.client',
isRowHeader: true,
label: $gettext('User Agent'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'stream',
label: $gettext('Stream'),
sortable: true,
selectable: true,
visible: true
},
{
key: 'location',
label: $gettext('Location'),
sortable: true,
selectable: true,
visible: true
}
];
const exportUrl = computed(() => {
const exportUrl = new URL(apiUrl.value, document.location.href);
const exportUrlParams = exportUrl.searchParams;
exportUrlParams.set('format', 'csv');
if (!isLive.value) {
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
}
return exportUrl.toString();
});
const totalListenerHours = computed(() => {
let tlh_seconds = 0;
filteredListeners.value.forEach(function (listener) {
tlh_seconds += listener.connected_time;
});
const tlh_hours = tlh_seconds / 3600;
return Math.round((tlh_hours + 0.00001) * 100) / 100;
});
const {axios} = useAxios();
const $datatable = ref<DataTableTemplateRef>(null);
const {navigate} = useHasDatatable($datatable);
const hasFilters: ComputedRef<boolean> = computed(() => {
return null !== filters.value.minLength
|| null !== filters.value.maxLength
|| ListenerTypeFilter.All !== filters.value.type;
});
const filteredListeners: ComputedRef<ApiListener[]> = computed(() => {
if (!hasFilters.value) {
return listeners.value;
}
return filter(
listeners.value,
(row: ApiListener) => {
const connectedTime: number = row.connected_time;
if (null !== filters.value.minLength && connectedTime < filters.value.minLength) {
return false;
}
if (null !== filters.value.maxLength && connectedTime > filters.value.maxLength) {
return false;
}
if (ListenerTypeFilter.All !== filters.value.type) {
if (ListenerTypeFilter.Mobile === filters.value.type && !row.device.is_mobile) {
return false;
} else if (ListenerTypeFilter.Desktop === filters.value.type && row.device.is_mobile) {
return false;
}
}
return true;
}
);
});
const updateListeners = () => {
const params: {
[key: string]: any
} = {};
if (!isLive.value) {
params.start = DateTime.fromJSDate(dateRange.value.startDate).toISO();
params.end = DateTime.fromJSDate(dateRange.value.endDate).toISO();
}
axios.get(apiUrl.value, {params: params}).then((resp) => {
listeners.value = resp.data;
navigate();
if (isLive.value) {
setTimeout(updateListeners, (!document.hidden) ? 15000 : 30000);
}
}).catch((error) => {
if (isLive.value && (!error.response || error.response.data.code !== 403)) {
setTimeout(updateListeners, (!document.hidden) ? 30000 : 120000);
}
});
};
watch(dateRange, updateListeners);
onMounted(updateListeners);
const setIsLive = (newValue: boolean) => {
isLive.value = newValue;
nextTick(updateListeners);
};
</script>