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>
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem>
|
||||
<RouterLink to="/stats">
|
||||
<font-awesome-icon icon="chart-line" />
|
||||
<span class="item-text">Statistics</span>
|
||||
</RouterLink>
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem @click="$emit('logout')">
|
||||
<font-awesome-icon icon="sign-out-alt" />
|
||||
<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 GPSData from './api/GPSData.vue'
|
||||
import Sessions from './api/Sessions.vue'
|
||||
import Stats from './api/Stats.vue'
|
||||
import Users from './api/Users.vue'
|
||||
|
||||
export default {
|
||||
|
@ -11,6 +12,7 @@ export default {
|
|||
Devices,
|
||||
GPSData,
|
||||
Sessions,
|
||||
Stats,
|
||||
Users,
|
||||
],
|
||||
}
|
||||
|
|
|
@ -1,12 +1,27 @@
|
|||
<script lang="ts">
|
||||
export default {
|
||||
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) {
|
||||
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 {
|
||||
|
|
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 Login from '../views/Login.vue'
|
||||
import Logout from '../views/Logout.vue'
|
||||
import Stats from '../views/Stats.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -26,6 +27,12 @@ const router = createRouter({
|
|||
component: API,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: Stats,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
$table-header-height: 2rem;
|
||||
|
||||
// Common element styles
|
||||
button {
|
||||
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;
|
||||
padding: 2em;
|
||||
|
||||
@include until(tablet) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
@ -32,6 +36,7 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
|
||||
@include media(tablet) {
|
||||
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 {
|
||||
public key: Record<string, any>;
|
||||
public count: number;
|
||||
public startDate: Optional<Date>;
|
||||
public endDate: Optional<Date>;
|
||||
public startDate: Date | undefined | null;
|
||||
public endDate: Date | undefined | null;
|
||||
|
||||
constructor(data: {
|
||||
key: Record<string, any>;
|
||||
count: number;
|
||||
startDate: Optional<Date>;
|
||||
endDate: Optional<Date>;
|
||||
startDate?: Date | undefined | null;
|
||||
endDate?: Date | undefined | null;
|
||||
}) {
|
||||
this.key = data.key;
|
||||
this.count = data.count;
|
||||
|
|
Loading…
Add table
Reference in a new issue