Added statistics panel
This commit is contained in:
parent
4f437a63ee
commit
36846164bd
13 changed files with 481 additions and 8 deletions
frontend/src
components
mixins
models
router
styles
views
src/responses
|
@ -32,6 +32,13 @@
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
|
<DropdownItem>
|
||||||
|
<RouterLink to="/stats">
|
||||||
|
<font-awesome-icon icon="chart-line" />
|
||||||
|
<span class="item-text">Statistics</span>
|
||||||
|
</RouterLink>
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem @click="$emit('logout')">
|
<DropdownItem @click="$emit('logout')">
|
||||||
<font-awesome-icon icon="sign-out-alt" />
|
<font-awesome-icon icon="sign-out-alt" />
|
||||||
<span class="item-text">Logout</span>
|
<span class="item-text">Logout</span>
|
||||||
|
|
88
frontend/src/components/stats/MetricsForm.vue
Normal file
88
frontend/src/components/stats/MetricsForm.vue
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<form class="metrics-form" @submit.prevent="$emit('submit', newMetrics)">
|
||||||
|
<div class="metrics">
|
||||||
|
<div v-for="enabled, metric in newMetrics" :key="metric" class="metric">
|
||||||
|
<label :for="metric">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="metric"
|
||||||
|
v-model="newMetrics[metric]"
|
||||||
|
/>
|
||||||
|
{{ displayName(metric) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button @click="$emit('close')">
|
||||||
|
<font-awesome-icon icon="fa-solid fa-xmark" />
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button type="submit">
|
||||||
|
<font-awesome-icon icon="fa-solid fa-check" />
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Text from '../../mixins/Text.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['close', 'submit'],
|
||||||
|
mixins: [Text],
|
||||||
|
props: {
|
||||||
|
metrics: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newMetrics: { ...this.metrics },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.metrics-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -3,6 +3,7 @@ import Auth from './api/Auth.vue'
|
||||||
import Devices from './api/Devices.vue'
|
import Devices from './api/Devices.vue'
|
||||||
import GPSData from './api/GPSData.vue'
|
import GPSData from './api/GPSData.vue'
|
||||||
import Sessions from './api/Sessions.vue'
|
import Sessions from './api/Sessions.vue'
|
||||||
|
import Stats from './api/Stats.vue'
|
||||||
import Users from './api/Users.vue'
|
import Users from './api/Users.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -11,6 +12,7 @@ export default {
|
||||||
Devices,
|
Devices,
|
||||||
GPSData,
|
GPSData,
|
||||||
Sessions,
|
Sessions,
|
||||||
|
Stats,
|
||||||
Users,
|
Users,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
formatDate(date: Date | number | string | null | undefined): string {
|
formatDate(date: Date | number | string | null | undefined, opts: {
|
||||||
|
dayOfWeek?: boolean,
|
||||||
|
seconds?: boolean,
|
||||||
|
} = {
|
||||||
|
dayOfWeek: true,
|
||||||
|
seconds: true,
|
||||||
|
}): string {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Date(date).toString().replace(/GMT.*/, '')
|
let dateStr = this.normalizeDate(date).toString().replace(/GMT.*/, '').trim() as string
|
||||||
|
if (!opts.dayOfWeek) {
|
||||||
|
dateStr = dateStr.slice(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.seconds) {
|
||||||
|
dateStr = dateStr.slice(0, -3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateStr
|
||||||
},
|
},
|
||||||
|
|
||||||
normalizeDate(date: any): Date | null {
|
normalizeDate(date: any): Date | null {
|
||||||
|
|
16
frontend/src/mixins/Text.vue
Normal file
16
frontend/src/mixins/Text.vue
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
displayName(text: string) {
|
||||||
|
if (!text?.length || text === 'undefined' || text === 'null' || text === 'None') {
|
||||||
|
return '<missing>'
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.replace(/^./, (str) => str.toUpperCase())
|
||||||
|
.trim();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
18
frontend/src/mixins/api/Stats.vue
Normal file
18
frontend/src/mixins/api/Stats.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import LocationStats from '../../models/LocationStats';
|
||||||
|
import StatsRequest from '../../models/StatsRequest';
|
||||||
|
import Common from './Common.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Common],
|
||||||
|
methods: {
|
||||||
|
async getStats(query: StatsRequest): Promise<LocationStats[]> {
|
||||||
|
return (
|
||||||
|
await this.request('/stats', {
|
||||||
|
query: query as Record<string, any>
|
||||||
|
}) || []
|
||||||
|
).map((record: any) => new LocationStats(record));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
1
frontend/src/models/LocationStats.ts
Symbolic link
1
frontend/src/models/LocationStats.ts
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../src/responses/LocationStats.ts
|
1
frontend/src/models/StatsRequest.ts
Symbolic link
1
frontend/src/models/StatsRequest.ts
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../../../src/requests/StatsRequest.ts
|
|
@ -4,6 +4,7 @@ import Devices from '../views/Devices.vue'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
import Logout from '../views/Logout.vue'
|
import Logout from '../views/Logout.vue'
|
||||||
|
import Stats from '../views/Stats.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -26,6 +27,12 @@ const router = createRouter({
|
||||||
component: API,
|
component: API,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/stats',
|
||||||
|
name: 'stats',
|
||||||
|
component: Stats,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
$table-header-height: 2rem;
|
||||||
|
|
||||||
// Common element styles
|
// Common element styles
|
||||||
button {
|
button {
|
||||||
margin: 0 0.5em;
|
margin: 0 0.5em;
|
||||||
|
@ -84,3 +86,41 @@ form {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
height: $table-header-height;
|
||||||
|
|
||||||
|
tr {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
height: calc(100% - #{$table-header-height});
|
||||||
|
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
|
|
||||||
|
@include until(tablet) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -32,6 +36,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
@include media(tablet) {
|
@include media(tablet) {
|
||||||
min-width: 30em;
|
min-width: 30em;
|
||||||
|
|
275
frontend/src/views/Stats.vue
Normal file
275
frontend/src/views/Stats.vue
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
<template>
|
||||||
|
<div class="stats view">
|
||||||
|
<div class="wrapper">
|
||||||
|
<h1>
|
||||||
|
<font-awesome-icon icon="chart-line" />
|
||||||
|
Statistics
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="loading-container" v-if="loading">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list table-container" v-else>
|
||||||
|
<table class="table" v-if="stats.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="key" v-for="attr in Object.keys(stats[0].key)" :key="attr">
|
||||||
|
{{ displayName(attr) }}
|
||||||
|
</th>
|
||||||
|
<th class="count"># of records</th>
|
||||||
|
<th class="date">First record</th>
|
||||||
|
<th class="date">Last record</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="stat in stats" :key="stat.key">
|
||||||
|
<td class="key" v-for="value, attr in stat.key" :key="attr">
|
||||||
|
{{ displayValue(attr, value) }}
|
||||||
|
</td>
|
||||||
|
<td class="count">{{ stat.count }}</td>
|
||||||
|
<td class="date">{{ displayDate(stat.startDate) }}</td>
|
||||||
|
<td class="date">{{ displayDate(stat.endDate) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="no-data" v-else>
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal :visible="showSelectForm" @close="closeForm">
|
||||||
|
<template v-slot:title>
|
||||||
|
Select metrics
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MetricsForm
|
||||||
|
:metrics="metrics"
|
||||||
|
v-if="showSelectForm"
|
||||||
|
@close="closeForm"
|
||||||
|
@submit="onMetricsSubmit" />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<FloatingButton
|
||||||
|
icon="fas fa-table"
|
||||||
|
title="Select metrics"
|
||||||
|
:primary="true"
|
||||||
|
@click="showSelectForm = true" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { getCountryData, getEmojiFlag } from 'countries-list';
|
||||||
|
import type { TCountryCode } from 'countries-list';
|
||||||
|
|
||||||
|
import Api from '../mixins/Api.vue';
|
||||||
|
import Dates from '../mixins/Dates.vue';
|
||||||
|
import FloatingButton from '../elements/FloatingButton.vue';
|
||||||
|
import Loading from '../elements/Loading.vue';
|
||||||
|
import LocationStats from '../models/LocationStats';
|
||||||
|
import MetricsForm from '../components/stats/MetricsForm.vue';
|
||||||
|
import Modal from '../elements/Modal.vue';
|
||||||
|
import StatsRequest from '../models/StatsRequest';
|
||||||
|
import Text from '../mixins/Text.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [
|
||||||
|
Api,
|
||||||
|
Dates,
|
||||||
|
Text,
|
||||||
|
],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
FloatingButton,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
MetricsForm,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
metrics: {
|
||||||
|
country: true,
|
||||||
|
locality: false,
|
||||||
|
address: false,
|
||||||
|
postalCode: false,
|
||||||
|
description: false,
|
||||||
|
},
|
||||||
|
showSelectForm: false,
|
||||||
|
stats: [] as LocationStats[],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
query() {
|
||||||
|
return new StatsRequest({
|
||||||
|
userId: this.$root.user.id,
|
||||||
|
groupBy: Object.entries(this.metrics)
|
||||||
|
.filter(([_, enabled]) => enabled)
|
||||||
|
.map(([metric]) => metric),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.stats = await this.getStats(this.query);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setURLQuery() {
|
||||||
|
const enabledMetrics = Object.entries(this.metrics)
|
||||||
|
.filter(([_, enabled]) => enabled)
|
||||||
|
.map(([metric]) => metric);
|
||||||
|
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}#groupBy=${enabledMetrics.sort().join(',')}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setState() {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash.slice(1));
|
||||||
|
const groupBy = params.get('groupBy');
|
||||||
|
if (!groupBy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = Object.fromEntries(
|
||||||
|
Object.entries(this.metrics).map(([key]) => [key, false]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const metric of groupBy.split(',')) {
|
||||||
|
if (metrics[metric] !== undefined) {
|
||||||
|
metrics[metric] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.metrics = metrics;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeForm() {
|
||||||
|
this.showSelectForm = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onMetricsSubmit(newMetrics: Record<string, boolean>) {
|
||||||
|
this.metrics = newMetrics;
|
||||||
|
this.closeForm();
|
||||||
|
},
|
||||||
|
|
||||||
|
displayValue(key: string, value: any) {
|
||||||
|
if (key === 'country') {
|
||||||
|
return this.displayCountry(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return this.displayDate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
displayCountry(countryCode?: string) {
|
||||||
|
if (!countryCode?.length) {
|
||||||
|
return "<missing>";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cc = countryCode.toUpperCase() as TCountryCode;
|
||||||
|
const country = getCountryData(cc);
|
||||||
|
if (!country) {
|
||||||
|
return countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${getEmojiFlag(cc)} ${country.name}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
displayDate(date: Date | string | number | null | undefined) {
|
||||||
|
return this.formatDate(date, { dayOfWeek: false, seconds: false });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
query: {
|
||||||
|
handler() {
|
||||||
|
this.setURLQuery();
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
showSelectForm(newValue: boolean) {
|
||||||
|
if (newValue) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.metricsForm?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
this.setState();
|
||||||
|
this.setURLQuery();
|
||||||
|
await this.refresh();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/styles/common.scss' as *;
|
||||||
|
|
||||||
|
.stats.view {
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include until(desktop) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include from(desktop) {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
&.count {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.date {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,16 +1,14 @@
|
||||||
import {Optional} from "~/types";
|
|
||||||
|
|
||||||
class LocationStats {
|
class LocationStats {
|
||||||
public key: Record<string, any>;
|
public key: Record<string, any>;
|
||||||
public count: number;
|
public count: number;
|
||||||
public startDate: Optional<Date>;
|
public startDate: Date | undefined | null;
|
||||||
public endDate: Optional<Date>;
|
public endDate: Date | undefined | null;
|
||||||
|
|
||||||
constructor(data: {
|
constructor(data: {
|
||||||
key: Record<string, any>;
|
key: Record<string, any>;
|
||||||
count: number;
|
count: number;
|
||||||
startDate: Optional<Date>;
|
startDate?: Date | undefined | null;
|
||||||
endDate: Optional<Date>;
|
endDate?: Date | undefined | null;
|
||||||
}) {
|
}) {
|
||||||
this.key = data.key;
|
this.key = data.key;
|
||||||
this.count = data.count;
|
this.count = data.count;
|
||||||
|
|
Loading…
Add table
Reference in a new issue