Add Dashboards (#41)

This commit is contained in:
Jonas Plum
2022-03-14 00:23:29 +01:00
committed by GitHub
parent 18a4dc54e7
commit 02c7da91da
30 changed files with 2824 additions and 279 deletions

View File

@@ -1,110 +1,123 @@
<template>
<v-main>
<div v-if="dashboard">
<h2 class="d-flex">
<span v-if="!editmode">{{ dashboard.name }}</span>
<v-text-field v-else v-model="dashboard.name" outlined dense class="mb-0" hide-details></v-text-field>
<v-spacer></v-spacer>
<v-btn v-if="editmode" small outlined @click="addWidget" class="mr-1">
<v-icon>mdi-plus</v-icon>
Add Widget
</v-btn>
<v-btn v-if="editmode" small outlined @click="save" class="mr-1">
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
<v-btn v-if="editmode && $route.params.id !== 'new'" small outlined @click="cancel">
<v-icon>mdi-cancel</v-icon>
Cancel
</v-btn>
<v-btn v-if="!editmode" small outlined @click="edit">
<v-icon>mdi-pencil</v-icon>
Edit
</v-btn>
</h2>
<v-row>
<v-col v-if="statistics" cols="12" lg="7">
<v-row>
<v-col cols="4">
<v-subheader>Unassigned tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND !owner' }
}">
{{ statistics.unassigned }}
</router-link>
</span>
<v-subheader>Your tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND owner == \'' + $store.state.user.id + '\'' }
}">
{{ $store.state.user.id in statistics.open_tickets_per_user ? statistics.open_tickets_per_user[$store.state.user.id] : 0 }}
</router-link>
</span>
</v-col>
<v-col cols="8">
<v-subheader>Open tickets per owner</v-subheader>
<bar-chart
v-if="open_tickets_per_user"
:chart-data="open_tickets_per_user"
:styles="{
width: '100%',
'max-height': '400px',
position: 'relative'
}"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { xAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] },
onClick: clickUser,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
></bar-chart>
</v-col>
</v-row>
<v-row>
<v-col cols="7">
<v-subheader>Tickets created per week</v-subheader>
<v-col v-for="(widget, index) in dashboard.widgets" :key="index" :cols="widget.width">
<v-card class="mb-2">
<v-card-title>
<span v-if="!editmode">{{ widget.name }}</span>
<v-text-field v-else outlined dense hide-details v-model="widget.name" class="mr-1"></v-text-field>
<v-btn v-if="editmode" outlined @click="removeWidget(index)">
<v-icon>mdi-close</v-icon>
Remove
</v-btn>
</v-card-title>
<v-card-text v-if="editmode">
<v-row>
<v-col cols="8">
<v-select label="Type" v-model="widget.type" :items="['line', 'bar', 'pie']"></v-select>
</v-col>
<v-col cols="4">
<v-text-field label="Width" type="number" v-model="widget.width"></v-text-field>
</v-col>
</v-row>
<v-text-field label="Aggregation" v-model="widget.aggregation"></v-text-field>
<v-text-field label="Filter" v-model="widget.filter" clearable></v-text-field>
</v-card-text>
<v-card-text v-if="data[index] === null">
{{ widgetErrors[index] }}
</v-card-text>
<div v-else>
<line-chart
v-if="tickets_per_week"
:chart-data="tickets_per_week"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { yAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] }
}"
v-if="widget.type === 'line' && data[index]"
:chart-data="data[index]"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: false,
scales: { yAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] }
}"
>
</line-chart>
</v-col>
<v-col cols="5">
<v-subheader>Ticket Types</v-subheader>
<pie-chart
v-if="tickets_per_type"
:chart-data="tickets_per_type"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
onClick: clickPie,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
v-if="widget.type === 'pie' && data[index]"
:chart-data="data[index]"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
}"
>
</pie-chart>
</v-col>
</v-row>
</v-col>
<v-col cols="12" lg="5">
<TicketList :type="this.$route.params.type" @click="open"></TicketList>
<bar-chart
v-if="widget.type === 'bar' && data[index]"
:chart-data="data[index]"
:styles="{
width: '100%',
'max-height': '400px',
position: 'relative'
}"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: false,
scales: { xAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] },
}"
></bar-chart>
</div>
</v-card>
</v-col>
</v-row>
</v-main>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {DashboardResponse, Widget} from "@/client";
import { API } from "@/services/api";
import {createHash} from "crypto";
import {colors} from "@/plugins/vuetify";
import LineChart from "../components/charts/Line";
import BarChart from "../components/charts/Bar";
import PieChart from "../components/charts/Doughnut";
import { API } from "@/services/api";
import {Statistics, TicketResponse} from "@/client";
import {DateTime} from "luxon";
import { colors } from "@/plugins/vuetify";
import TicketList from "@/components/TicketList.vue";
import { createHash } from "crypto";
import {ChartData} from "chart.js";
import {AxiosError, AxiosTransformer} from "axios";
interface State {
dashboard?: DashboardResponse;
undodashboard?: DashboardResponse;
data: Record<string, any>;
editmode: boolean;
widgetErrors: Record<number, string>;
}
export default Vue.extend({
name: "Dashboard",
@@ -112,108 +125,130 @@ export default Vue.extend({
LineChart,
BarChart,
PieChart,
TicketList
},
data() {
return {
statistics: (undefined as unknown) as Statistics
};
},
computed: {
tickets_per_type: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.tickets_per_type, (count, type) => {
data.labels.push(type);
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(type));
})
return data
data: (): State => ({
dashboard: undefined,
undodashboard: undefined,
data: {},
editmode: false,
widgetErrors: {},
}),
watch: {
$route: function () {
this.loadDashboard();
},
open_tickets_per_user: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.open_tickets_per_user, (count, user) => {
if (!user) {
data.labels.push("unassigned");
} else {
data.labels.push(user);
}
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(user));
})
return data
},
tickets_per_week: function () {
let data = {labels: [] as Array<string>, datasets: [{backgroundColor: [] as Array<string>, data: [] as Array<number> }]}
this.lodash.forEach(this.weeks(), (week) => {
data.labels.push(week);
if (week in this.statistics.tickets_per_week) {
data.datasets[0].data.push(this.statistics.tickets_per_week[week]);
} else {
data.datasets[0].data.push(0);
}
data.datasets[0].backgroundColor.push("#607d8b");
})
return data
}
},
methods: {
open: function (ticket: TicketResponse) {
if (ticket.id === undefined) {
return;
edit: function () {
this.undodashboard = this.lodash.cloneDeep(this.dashboard);
this.editmode = true;
},
save: function () {
if (!this.dashboard) {
return
}
this.$router.push({
name: "Ticket",
params: {type: '-', id: ticket.id.toString()}
});
},
clickUser: function (evt, elem) {
let owner = this.open_tickets_per_user.labels[elem[0]._index];
let query = 'status == \'open\' AND owner == \'' + owner + '\'';
let widgets = [] as Array<Widget>;
this.lodash.forEach(this.dashboard.widgets, (widget) => {
widget.width = this.lodash.toInteger(widget.width);
if (!widget.filter) {
this.lodash.unset(widget, "filter")
}
widgets.push(widget);
})
this.dashboard.widgets = widgets;
if (owner == 'unassigned') {
query = 'status == \'open\' AND !owner';
if (this.$route.params.id === 'new') {
API.createDashboard(this.dashboard).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
this.editmode = false;
this.$router.push({ name: "Dashboard", params: { id: response.data.id }})
})
} else {
API.updateDashboard(this.dashboard.id, this.dashboard).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
this.editmode = false;
})
}
},
cancel: function () {
this.dashboard = this.lodash.cloneDeep(this.undodashboard);
this.editmode = false;
},
addWidget: function () {
if (!this.dashboard) {
return
}
this.$router.push({
name: "TicketList",
params: {query: query}
});
this.dashboard.widgets.push({name: "new widget", width: 6, aggregation: "", type: "line"})
},
clickPie: function (evt, elem) {
this.$router.push({
name: "TicketList",
params: {type: this.tickets_per_type.labels[elem[0]._index]}
});
removeWidget: function (id: number) {
if (!this.dashboard) {
return
}
console.log(id);
let widgets = this.lodash.cloneDeep(this.dashboard.widgets);
this.lodash.pullAt(widgets, [id]);
Vue.set(this.dashboard, "widgets", widgets);
},
loadDashboard: function () {
if (this.$route.params.id === 'new') {
this.dashboard = {
name: "New dashboard",
widgets: [{name: "new widget", width: 6, aggregation: "", type: "line"}],
} as DashboardResponse
this.editmode = true;
} else {
API.getDashboard(this.$route.params.id).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
});
}
},
loadWidgetData: function (widgets: Array<Widget>) {
this.lodash.forEach(widgets, (widget: Widget, index: number) => {
let widgetErrors = {};
let defaultTransformers = this.axios.defaults.transformResponse as AxiosTransformer[]
let transformResponse = defaultTransformers.concat((data) => {
data.notoast = true;
return data
});
API.dashboardData(widget.aggregation, widget.filter, {transformResponse: transformResponse}).then((response) => {
let d = { labels: [], datasets: [{data: [], backgroundColor: []}] } as ChartData;
this.lodash.forEach(response.data, (v: any, k: string) => {
// @ts-expect-error T2532
d.labels.push(k)
// @ts-expect-error T2532
d.datasets[0].data.push(v)
if (widget.type !== 'line') {
// @ts-expect-error T2532
d.datasets[0].backgroundColor.push(this.color(this.lodash.toString(v)));
}
})
Vue.set(this.data, index, d);
}).catch((err: AxiosError) => {
widgetErrors[index] = this.lodash.toString(err.response?.data.error);
Vue.set(this.data, index, null);
})
Vue.set(this, 'widgetErrors', widgetErrors);
})
},
color: function (s: string): string {
let pos = createHash('md5').update(s).digest().readUInt32BE(0) % colors.length;
return colors[pos];
},
fillData() {
API.getStatistics().then(response => {
this.statistics = response.data;
});
},
weeks: function () {
let w = [] as Array<string>;
for (let i = 0; i < 53; i++) {
w.push(DateTime.utc().minus({ weeks: i }).toFormat("kkkk-WW"))
}
this.lodash.reverse(w);
return w
}
},
mounted() {
this.fillData();
this.loadDashboard();
}
});
</script>
<style>
canvas {
position: relative !important;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="dashboards"
routername="Dashboard"
itemid="id"
itemname="name"
singular="Dashboard"
plural="Dashboards"
writepermission="admin:dashboard:write"
@delete="deleteDashboard"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {Dashboard} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
dashboards: Array<Dashboard>;
}
export default Vue.extend({
name: "DashboardList",
components: {List},
data: (): State => ({
dashboards: [],
}),
methods: {
loadDashboards() {
API.listDashboards().then((response) => {
this.dashboards = response.data;
});
},
deleteDashboard(id: string) {
API.deleteDashboard(id).then(() => {
this.loadDashboards();
})
}
},
mounted() {
this.loadDashboards();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "dashboard/")) {
reload = true;
}
});
if (reload) {
this.loadDashboards()
}
})
},
});
</script>

219
ui/src/views/Home.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<v-main>
<v-row>
<v-col v-if="statistics" cols="12" lg="7">
<v-row>
<v-col cols="4">
<v-subheader>Unassigned tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND !owner' }
}">
{{ statistics.unassigned }}
</router-link>
</span>
<v-subheader>Your tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND owner == \'' + $store.state.user.id + '\'' }
}">
{{ $store.state.user.id in statistics.open_tickets_per_user ? statistics.open_tickets_per_user[$store.state.user.id] : 0 }}
</router-link>
</span>
</v-col>
<v-col cols="8">
<v-subheader>Open tickets per owner</v-subheader>
<bar-chart
v-if="open_tickets_per_user"
:chart-data="open_tickets_per_user"
:styles="{
width: '100%',
'max-height': '400px',
position: 'relative'
}"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { xAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] },
onClick: clickUser,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
></bar-chart>
</v-col>
</v-row>
<v-row>
<v-col cols="7">
<v-subheader>Tickets created per week</v-subheader>
<line-chart
v-if="tickets_per_week"
:chart-data="tickets_per_week"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { yAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] }
}"
>
</line-chart>
</v-col>
<v-col cols="5">
<v-subheader>Ticket Types</v-subheader>
<pie-chart
v-if="tickets_per_type"
:chart-data="tickets_per_type"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
onClick: clickPie,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
>
</pie-chart>
</v-col>
</v-row>
</v-col>
<v-col cols="12" lg="5">
<TicketList :type="this.$route.params.type" @click="open"></TicketList>
</v-col>
</v-row>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import LineChart from "../components/charts/Line";
import BarChart from "../components/charts/Bar";
import PieChart from "../components/charts/Doughnut";
import { API } from "@/services/api";
import {Statistics, TicketResponse} from "@/client";
import {DateTime} from "luxon";
import { colors } from "@/plugins/vuetify";
import TicketList from "@/components/TicketList.vue";
import { createHash } from "crypto";
export default Vue.extend({
name: "Home",
components: {
LineChart,
BarChart,
PieChart,
TicketList
},
data() {
return {
statistics: (undefined as unknown) as Statistics
};
},
computed: {
tickets_per_type: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.tickets_per_type, (count, type) => {
data.labels.push(type);
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(type));
})
return data
},
open_tickets_per_user: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.open_tickets_per_user, (count, user) => {
if (!user) {
data.labels.push("unassigned");
} else {
data.labels.push(user);
}
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(user));
})
return data
},
tickets_per_week: function () {
let data = {labels: [] as Array<string>, datasets: [{backgroundColor: [] as Array<string>, data: [] as Array<number> }]}
this.lodash.forEach(this.weeks(), (week) => {
data.labels.push(week);
if (week in this.statistics.tickets_per_week) {
data.datasets[0].data.push(this.statistics.tickets_per_week[week]);
} else {
data.datasets[0].data.push(0);
}
data.datasets[0].backgroundColor.push("#607d8b");
})
return data
}
},
methods: {
open: function (ticket: TicketResponse) {
if (ticket.id === undefined) {
return;
}
this.$router.push({
name: "Ticket",
params: {type: '-', id: ticket.id.toString()}
});
},
clickUser: function (evt, elem) {
let owner = this.open_tickets_per_user.labels[elem[0]._index];
let query = 'status == \'open\' AND owner == \'' + owner + '\'';
if (owner == 'unassigned') {
query = 'status == \'open\' AND !owner';
}
this.$router.push({
name: "TicketList",
params: {query: query}
});
},
clickPie: function (evt, elem) {
this.$router.push({
name: "TicketList",
params: {type: this.tickets_per_type.labels[elem[0]._index]}
});
},
color: function (s: string): string {
let pos = createHash('md5').update(s).digest().readUInt32BE(0) % colors.length;
return colors[pos];
},
fillData() {
API.getStatistics().then(response => {
this.statistics = response.data;
});
},
weeks: function () {
let w = [] as Array<string>;
for (let i = 0; i < 53; i++) {
w.push(DateTime.utc().minus({ weeks: i }).toFormat("kkkk-WW"))
}
this.lodash.reverse(w);
return w
}
},
mounted() {
this.fillData();
}
});
</script>
<style>
canvas {
position: relative !important;
margin: 0 auto;
}
</style>