Add new dashboard (with new metrics)

This commit is contained in:
Kailash Nadh 2020-07-04 22:25:02 +05:30
parent 97583fe4b4
commit feb5ba09be
14 changed files with 623 additions and 82 deletions

View File

@ -17,10 +17,6 @@ type configScript struct {
Messengers []string `json:"messengers"`
}
type dashboardStats struct {
Stats types.JSONText `db:"stats"`
}
// handleGetConfigScript returns general configuration as a Javascript
// variable that can be included in an HTML page directly.
func handleGetConfigScript(c echo.Context) error {
@ -41,17 +37,32 @@ func handleGetConfigScript(c echo.Context) error {
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
}
// handleGetDashboardStats returns general states for the dashboard.
func handleGetDashboardStats(c echo.Context) error {
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
func handleGetDashboardCharts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out dashboardStats
out types.JSONText
)
if err := app.queries.GetDashboardStats.Get(&out); err != nil {
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out.Stats})
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetDashboardCounts returns stats counts to show on the dashboard.
func handleGetDashboardCounts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out types.JSONText
)
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{out})
}

View File

@ -11,6 +11,7 @@
"dependencies": {
"axios": "^0.19.2",
"buefy": "^0.8.20",
"c3": "^0.7.18",
"core-js": "^3.6.5",
"dayjs": "^1.8.28",
"humps": "^2.0.1",
@ -20,6 +21,7 @@
"quill-delta": "^4.2.2",
"sass-loader": "^8.0.2",
"vue": "^2.6.11",
"vue-c3": "^1.2.11",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"

View File

@ -79,6 +79,13 @@ http.interceptors.response.use((resp) => {
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
// Dashboard
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
{ loading: models.dashboard });
export const getDashboardCharts = () => http.get('/api/dashboard/charts',
{ loading: models.dashboard });
// Lists.
export const getLists = () => http.get('/api/lists',
{ loading: models.lists, store: models.lists });

View File

@ -111,7 +111,7 @@ section {
margin-right: 0;
}
> li {
margin-bottom: 5px;
margin-bottom: 15px;
}
}
.logo {
@ -229,20 +229,34 @@ section {
/* Dashboard */
section.dashboard {
.counts {
.title {
margin-bottom: 1rem;
}
.level-item {
background-color: $white-bis;
padding: 30px;
margin: 10px;
.title {
margin-bottom: 0.5rem;
}
&:first-child, &:last-child {
margin: 0;
}
.level-item {
background-color: $white-bis;
padding: 30px;
margin: 10px;
&:first-child, &:last-child {
margin: 0;
}
}
label {
font-weight: bold;
display: inline-block;
min-width: 50px;
text-align: right;
}
.tile {
position: relative;
}
.charts {
min-height: 200px;
}
}
/* Lists page */
@ -429,6 +443,39 @@ section.campaign {
}
}
.c3 {
.c3-chart-lines .c3-line {
stroke-width: 2px;
}
.c3-axis-x .tick line,
.c3-axis-y .tick line {
display: none;
}
text {
fill: $grey;
font-family: $body-family;
font-size: 11px;
}
.c3-axis path, .c3-axis line {
stroke: #eee;
}
.c3-tooltip {
border: 0;
background-color: #fff;
empty-cells: show;
box-shadow: none;
opacity: 0.9;
tr {
border: 0;
}
th {
background: $white;
}
}
}
@media screen and (max-width: 1450px) and (min-width: 769px) {
section.campaigns {
/* Fold the stats labels until the card view */
@ -478,3 +525,9 @@ section.campaign {
margin: 15px;
}
}
@media screen and (max-width: 840px) {
section.dashboard label {
min-width: auto;
}
}

View File

@ -0,0 +1,20 @@
<template>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
</p>
<p>{{ !label ? 'Nothing here yet' : label }}</p>
</div>
</section>
</template>
<script>
export default {
name: 'EmptyPlaceholder',
props: {
icon: String,
label: String,
},
};

View File

@ -1,4 +1,5 @@
export const models = Object.freeze({
dashboard: 'dashboard',
lists: 'lists',
subscribers: 'subscribers',
campaigns: 'campaigns',
@ -20,3 +21,7 @@ export const storeKeys = Object.freeze({
});
export const timestamp = 'ddd D MMM YYYY, hh:mm A';
export const colors = Object.freeze({
primary: '#7f2aff',
});

View File

@ -31,6 +31,10 @@ export default class utils {
static validateEmail = (e) => e.match(reEmail);
static niceNumber = (n) => {
if (n === null || n === undefined) {
return 0;
}
let pfx = '';
let div = 1;

View File

@ -4,55 +4,215 @@
<div class="column is-two-thirds">
<h1 class="title is-5">{{ dayjs().format("ddd, DD MMM") }}</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
</div>
</header>
<div class="columns counts">
<div class="column is-half">
<div class="level">
<div class="level-item has-text-centered">
<div>
<p class="title">0</p>
<p class="heading">Subscribers</p>
<section class="counts wrap-small">
<div class="tile is-ancestor">
<div class="tile is-vertical is-12">
<div class="tile">
<div class="tile is-parent is-vertical">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
<p class="is-size-6 has-text-grey">Lists</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
single opt-in
</li>
<li>
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
double opt-in</li>
</ul>
</div>
</div>
</article><!-- lists -->
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
<p class="is-size-6 has-text-grey">Campaigns</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
<label>{{ num }}</label> {{ status }}
</li>
</ul>
</div>
</div>
</article><!-- campaigns -->
</div><!-- block -->
<div class="tile is-parent">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
<p class="is-size-6 has-text-grey">Subscribers</p>
</div>
<div class="column is-6">
<ul class="no is-size-7 has-text-grey">
<li>
<label>{{ $utils.niceNumber(counts.subscribers.blacklisted) }}</label>
blacklisted
</li>
<li>
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
orphans
</li>
</ul>
</div><!-- subscriber breakdown -->
</div><!-- subscriber columns -->
<hr />
<div class="columns">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
<p class="is-size-6 has-text-grey">Messages sent</p>
</div>
</div>
</article><!-- subscribers -->
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title">0</p>
<p class="heading">Lists</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title">0</p>
<p class="heading">Campaigns</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title">0</p>
<p class="heading">Messages sent</p>
</div>
<div class="tile is-parent">
<b-loading v-if="isChartsLoading" active :is-full-page="false" />
<article class="tile is-child notification charts">
<div class="columns">
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">Campaign views</h3>
<vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
<div class="column is-6">
<h3 class="title is-size-6 has-text-right">Link clicks</h3>
<vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" />
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</div><!-- tile block -->
</section>
</section>
</template>
<style lang="css">
@import "~c3/c3.css";
</style>
<script>
import Vue from 'vue';
import VueC3 from 'vue-c3';
import dayjs from 'dayjs';
import { colors } from '../constants';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
export default Vue.extend({
name: 'Home',
components: {
EmptyPlaceholder,
VueC3,
},
data() {
return {
// Unique Vue() instances for each chart.
chartViewsInst: null,
chartClicksInst: null,
isChartsLoading: true,
isCountsLoading: true,
counts: {
lists: {},
subscribers: {},
campaigns: {},
messages: 0,
},
};
},
methods: {
makeChart(label, data) {
const conf = {
data: {
columns: [
[label, ...data.map((d) => d.count).reverse()],
],
type: 'spline',
color() {
return colors.primary;
},
},
axis: {
x: {
type: 'category',
categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
tick: {
rotate: -45,
multiline: false,
culling: { max: 10 },
},
},
},
legend: {
show: false,
},
};
return conf;
},
},
computed: {
dayjs() {
return dayjs;
},
},
mounted() {
// Pull the counts.
this.$api.getDashboardCounts().then((r) => {
this.counts = r.data;
this.isCountsLoading = false;
});
// Pull the charts.
this.$api.getDashboardCharts().then((r) => {
this.isChartsLoading = false;
// vue-c3 lib requires unique instances of Vue() to communicate.
if (r.data.campaignViews.length > 0) {
this.chartViewsInst = this;
this.$nextTick(() => {
this.chartViewsInst.$emit('init',
this.makeChart('Campaign views', r.data.campaignViews));
});
}
if (r.data.linkClicks.length > 0) {
this.chartClicksInst = new Vue();
this.$nextTick(() => {
this.chartClicksInst.$emit('init',
this.makeChart('Link clicks', r.data.linkClicks));
});
}
});
},
});
</script>

View File

@ -40,7 +40,7 @@
</router-link>
</b-table-column>
<b-table-column field="subscribers" label="Subscribers" numeric sortable centered>
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ props.row.subscriberCount }}
</router-link>
@ -78,7 +78,7 @@
<p>
<b-icon icon="plus" size="is-large" />
</p>
<p>Nothing here.</p>
<p>Nothing here yet.</p>
</div>
</section>
</template>

277
frontend/yarn.lock vendored
View File

@ -1992,6 +1992,13 @@ bytes@3.1.0:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
c3@^0.7.18:
version "0.7.18"
resolved "https://registry.yarnpkg.com/c3/-/c3-0.7.18.tgz#a94228191b6178288fa416a6135c9624abd8dc45"
integrity sha512-ioiqCvET2sjAn80V3qVBEkyAtCH3tktZsz9SylGmUeeGEfqZxZfq9qRCxfgl64LpA6d9/Oz4C8oYEIXHbMX/Ow==
dependencies:
d3 "^5.8.0"
cacache@^12.0.2, cacache@^12.0.3:
version "12.0.4"
resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
@ -2414,16 +2421,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
commander@2, commander@^2.18.0, commander@^2.20.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@2.17.x:
version "2.17.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
commander@^2.18.0, commander@^2.20.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@~2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
@ -2863,6 +2870,254 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-axis@1:
version "1.0.12"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
d3-brush@1:
version "1.1.5"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-1.1.5.tgz#066b8e84d17b192986030446c97c0fba7e1bacdc"
integrity sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==
dependencies:
d3-dispatch "1"
d3-drag "1"
d3-interpolate "1"
d3-selection "1"
d3-transition "1"
d3-chord@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-1.0.6.tgz#309157e3f2db2c752f0280fedd35f2067ccbb15f"
integrity sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==
dependencies:
d3-array "1"
d3-path "1"
d3-collection@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
d3-color@1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
d3-contour@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3"
integrity sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==
dependencies:
d3-array "^1.1.1"
d3-dispatch@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
d3-drag@1:
version "1.2.5"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
integrity sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==
dependencies:
d3-dispatch "1"
d3-selection "1"
d3-dsv@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c"
integrity sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==
dependencies:
commander "2"
iconv-lite "0.4"
rw "1"
d3-ease@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.6.tgz#ebdb6da22dfac0a22222f2d4da06f66c416a0ec0"
integrity sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ==
d3-fetch@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-1.2.0.tgz#15ce2ecfc41b092b1db50abd2c552c2316cf7fc7"
integrity sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==
dependencies:
d3-dsv "1"
d3-force@1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.2.1.tgz#fd29a5d1ff181c9e7f0669e4bd72bdb0e914ec0b"
integrity sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==
dependencies:
d3-collection "1"
d3-dispatch "1"
d3-quadtree "1"
d3-timer "1"
d3-format@1:
version "1.4.4"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.4.tgz#356925f28d0fd7c7983bfad593726fce46844030"
integrity sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==
d3-geo@1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.1.tgz#7fc2ab7414b72e59fbcbd603e80d9adc029b035f"
integrity sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==
dependencies:
d3-array "1"
d3-hierarchy@1:
version "1.1.9"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
d3-interpolate@1:
version "1.4.0"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
dependencies:
d3-color "1"
d3-path@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
d3-polygon@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
d3-quadtree@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135"
integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==
d3-random@1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291"
integrity sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==
d3-scale-chromatic@1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98"
integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==
dependencies:
d3-color "1"
d3-interpolate "1"
d3-scale@2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
dependencies:
d3-array "^1.2.0"
d3-collection "1"
d3-format "1"
d3-interpolate "1"
d3-time "1"
d3-time-format "2"
d3-selection@1, d3-selection@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.1.tgz#98eedbbe085fbda5bafa2f9e3f3a2f4d7d622a98"
integrity sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA==
d3-shape@1:
version "1.3.7"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==
dependencies:
d3-path "1"
d3-time-format@2:
version "2.2.3"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.2.3.tgz#0c9a12ee28342b2037e5ea1cf0b9eb4dd75f29cb"
integrity sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==
dependencies:
d3-time "1"
d3-time@1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
d3-timer@1:
version "1.0.10"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
d3-transition@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==
dependencies:
d3-color "1"
d3-dispatch "1"
d3-ease "1"
d3-interpolate "1"
d3-selection "^1.1.0"
d3-timer "1"
d3-voronoi@1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"
integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==
d3-zoom@1:
version "1.8.3"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.8.3.tgz#b6a3dbe738c7763121cd05b8a7795ffe17f4fc0a"
integrity sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==
dependencies:
d3-dispatch "1"
d3-drag "1"
d3-interpolate "1"
d3-selection "1"
d3-transition "1"
d3@^5.8.0:
version "5.16.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-5.16.0.tgz#9c5e8d3b56403c79d4ed42fbd62f6113f199c877"
integrity sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==
dependencies:
d3-array "1"
d3-axis "1"
d3-brush "1"
d3-chord "1"
d3-collection "1"
d3-color "1"
d3-contour "1"
d3-dispatch "1"
d3-drag "1"
d3-dsv "1"
d3-ease "1"
d3-fetch "1"
d3-force "1"
d3-format "1"
d3-geo "1"
d3-hierarchy "1"
d3-interpolate "1"
d3-path "1"
d3-polygon "1"
d3-quadtree "1"
d3-random "1"
d3-scale "2"
d3-scale-chromatic "1"
d3-selection "1"
d3-shape "1"
d3-time "1"
d3-time-format "2"
d3-timer "1"
d3-transition "1"
d3-voronoi "1"
d3-zoom "1"
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@ -4504,7 +4759,7 @@ humps@^2.0.1:
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=
iconv-lite@0.4.24, iconv-lite@^0.4.24:
iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -7397,6 +7652,11 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
rw@1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
rxjs@^6.5.3:
version "6.5.5"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
@ -8646,6 +8906,11 @@ vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
vue-c3@^1.2.11:
version "1.2.11"
resolved "https://registry.yarnpkg.com/vue-c3/-/vue-c3-1.2.11.tgz#6937f0dd54addab2b76de74cd30c0ab9ad788080"
integrity sha512-jxYZ726lKO1Qa+CHOcekPD4ZIwcMQy2LYDafYy2jYD1oswAo/4SnEJmbwp9X+NWzZg/KIAijeB9ImS7Gfvhceg==
vue-eslint-parser@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.1.0.tgz#9cdbcc823e656b087507a1911732b867ac101e83"

View File

@ -38,7 +38,8 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
func registerHTTPHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
e.GET("/api/config.js", handleGetConfigScript)
e.GET("/api/dashboard/stats", handleGetDashboardStats)
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)

View File

@ -87,10 +87,13 @@ func initFS(staticDir string) stuffbin.FileSystem {
// initDB initializes the main DB connection pool and parse and loads the app's
// SQL queries into a prepared query map.
func initDB() *sqlx.DB {
var dbCfg dbConf
if err := ko.Unmarshal("db", &dbCfg); err != nil {
lo.Fatalf("error loading db config: %v", err)
}
lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
db, err := connectDB(dbCfg)
if err != nil {
lo.Fatalf("error connecting to DB: %v", err)

View File

@ -11,7 +11,8 @@ import (
// Queries contains all prepared SQL queries.
type Queries struct {
GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"`
GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"`
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`

View File

@ -678,21 +678,8 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
RETURNING (SELECT url FROM link);
-- name: get-dashboard-stats
WITH lists AS (
SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row
),
subs AS (
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row
),
orphans AS (
SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
),
camps AS (
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row
),
clicks AS (
-- name: get-dashboard-charts
WITH clicks AS (
-- Clicks by day for the last 3 months
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (SELECT COUNT(*) AS count, created_at::DATE as date
@ -706,9 +693,31 @@ views AS (
FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
) row
)
SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'),
'subscribers', COALESCE((SELECT * FROM subs), '[]'),
'orphan_subscribers', (SELECT * FROM orphans),
'campaigns', COALESCE((SELECT * FROM camps), '[]'),
'link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;
SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]'));
-- name: get-dashboard-counts
SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM subscribers),
'blacklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blacklisted'),
'orphans', (
SELECT COUNT(id) FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
)
),
'lists', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM lists),
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
),
'campaigns', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM campaigns),
'by_status', (
SELECT JSON_OBJECT_AGG (status, num) FROM
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
)
),
'messages', (SELECT SUM(sent) AS messages FROM campaigns));