support analytics dashboard

This commit is contained in:
2023-05-25 21:54:59 +10:00
parent d0c4f7eea2
commit e136c910b5
5 changed files with 340 additions and 3 deletions

View File

@@ -1,5 +1,35 @@
export type Booleanish = boolean | "true" | "false";
export type EmptyObject = { [key: string]: never };
export interface SessionRequest {
id: number;
session_id: number;
type: string;
path: string;
created_at: string;
updated_at: string;
}
export interface Session {
id: number;
ip: string;
useragent: string;
created_at: string;
updated_at: string;
ended_at: string;
requests?: SessionRequest[];
}
export interface SessionCollection {
sessions: Session[];
total: number;
}
export interface SessionRequestCollection {
session: Session;
}
export interface Event {
id: string;
title: string;

View File

@@ -198,6 +198,31 @@ export const routes = [
/* webpackPrefetch: true */ "@/views/dashboard/Dashboard.vue"
),
},
{
path: "analytics",
children: [
{
path: "",
name: "dashboard-analytics-list",
meta: {
title: "Analytics",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/AnalyticsList.vue"),
},
{
path: ":id",
name: "dashboard-analytics-item",
meta: {
title: "Analytics Session",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/AnalyticsItem.vue"),
},
],
},
{
path: "articles",
children: [
@@ -471,7 +496,7 @@ router.beforeEach(async (to, from, next) => {
url: "/analytics",
body: {
type: "pageview",
attribute: to.fullPath,
path: to.fullPath,
},
}).catch(() => {
/* empty */

View File

@@ -0,0 +1,71 @@
<template>
<SMPage :page-error="pageError" permission="admin/analytics">
<SMMastHead
:title="pageHeading"
:back-link="{ name: 'dashboard-analytics-list' }"
back-title="Back to Analytics" />
<SMContainer class="flex-grow-1">
<div>{{ sessionData.ip }}</div>
<div>{{ sessionData.useragent }}</div>
<div>{{ sessionData.created_at }}</div>
<div>{{ sessionData.ended_at }}</div>
<div v-for="request of sessionData.requests" :key="request.id">
<div>{{ request.type }}</div>
<div>{{ request.path }}</div>
<div>{{ request.created_at }}</div>
</div>
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRoute } from "vue-router";
import { api } from "../../helpers/api";
import {
EmptyObject,
Session,
SessionRequestCollection,
} from "../../helpers/api.types";
import SMMastHead from "../../components/SMMastHead.vue";
type SessionOrEmpty = Session | EmptyObject;
const route = useRoute();
let pageError = ref(200);
const pageHeading = `Session ${route.params.id}`;
const sessionData = ref<SessionOrEmpty>({});
/**
* Load the page data.
*/
const loadData = async () => {
try {
if (route.params.id) {
// form.loading(true);
let result = await api.get({
url: "/analytics/{id}",
params: {
id: route.params.id,
},
});
const data = result.data as SessionRequestCollection;
if (data && data.session) {
sessionData.value = data.session;
} else {
pageError.value = 404;
}
}
} catch (error) {
pageError.value = error.status;
} finally {
// form.loading(false);
}
};
loadData();
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,211 @@
<template>
<SMPage permission="admin/analytics">
<SMMastHead
title="Analytics"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<SMContainer class="flex-grow-1">
<SMToolbar>
<SMInput
v-model="itemSearch"
label="Search"
class="toolbar-search"
@keyup.enter="handleSearch">
<template #append>
<SMButton
type="primary"
label="Search"
icon="search-outline"
@click="handleSearch" />
</template>
</SMInput>
</SMToolbar>
<SMLoading large v-if="itemsLoading" />
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMNoItems v-if="items.length == 0" text="No Sessions Found" />
<SMTable
v-else
:headers="headers"
:items="items"
@row-click="handleView">
</SMTable>
</template>
</SMContainer>
</SMPage>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog";
import { api } from "../../helpers/api";
import {
EventCollection,
Event,
SessionCollection,
Session,
} from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime";
import { updateRouterParams } from "../../helpers/url";
import { useToastStore } from "../../store/ToastStore";
import { toTitleCase } from "../../helpers/string";
import SMButton from "../../components/SMButton.vue";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMInput from "../../components/SMInput.vue";
import SMLoading from "../../components/SMLoading.vue";
import SMMastHead from "../../components/SMMastHead.vue";
import SMNoItems from "../../components/SMNoItems.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMTable from "../../components/SMTable.vue";
import SMToolbar from "../../components/SMToolbar.vue";
const route = useRoute();
const router = useRouter();
const toastStore = useToastStore();
const items = ref([]);
const itemsLoading = ref(true);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [
{ text: "Session", value: "id", sortable: true },
{ text: "IP", value: "ip", sortable: true },
{ text: "Started", value: "created_at", sortable: true },
{ text: "Ended", value: "ended_at", sortable: true },
];
/**
* Watch if page number changes.
*/
watch(itemsPage, () => {
handleLoad();
});
/**
* Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try {
let params = {
page: itemsPage.value,
limit: itemsPerPage,
sort: "-id",
};
if (itemSearch.value.length > 0) {
params[
"filter"
] = `id:${itemSearch.value},OR,ip:${itemSearch.value},OR,path:${itemSearch.value}`;
}
let result = await api.get({
url: "/analytics",
params: params,
});
const data = result.data as SessionCollection;
data.sessions.forEach(async (row) => {
if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, {
format: "ymd",
utc: true,
}).format("dd MMM yyyy h:mm AA");
}
if (row.ended_at !== "undefined") {
row.ended_at = new SMDate(row.ended_at, {
format: "ymd",
utc: true,
}).format("dd MMM yyyy h:mm AA");
}
items.value.push(row);
});
itemsTotal.value = data.total;
} catch (error) {
console.log(error);
if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content:
"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally {
itemsLoading.value = false;
}
};
/**
* User requests to edit the item
*
* @param {Session} item The event item.
*/
const handleView = (item: Session) => {
router.push({
name: "dashboard-analytics-item",
params: { id: item.id },
query: {
return: encodeURIComponent(
window.location.pathname + window.location.search
),
},
});
};
handleLoad();
</script>
<style lang="scss">
.page-dashboard-event-list {
.toolbar-search {
max-width: 350px;
}
.table tr {
td:first-of-type,
td:nth-of-type(2) {
word-break: break-all;
}
td:not(:first-of-type) {
white-space: nowrap;
}
}
}
@media only screen and (max-width: 768px) {
.page-dashboard-event-list {
.toolbar-search {
max-width: none;
}
}
}
</style>

View File

@@ -43,8 +43,8 @@
</router-link>
<router-link
v-if="userStore.permissions.includes('admin/media')"
:to="{ name: 'dashboard-media-list' }"
class="admin-card media">
:to="{ name: 'dashboard-analytics-list' }"
class="admin-card analytics">
<ion-icon name="bar-chart-outline" />
<h3>Analytics</h3>
</router-link>