Преглед на файлове

Add a `?minimal` mode to GET /lists API.

Passing `?minimal=true` to the /lists API returns all lists without
additional metadata (subscriber count) which is orders of magnitude
faster than counting subscribers per list in large DBs.

The frontend intitialization always calls the GET /lists API on load
to keep it available in multiple contexts like the new campaign page.
However, this "boot up" call does not need additional metdata. This
initialization GET /lists call now calls /lists?minimal=true.
Kailash Nadh преди 3 години
родител
ревизия
4e5e466b03
променени са 6 файла, в които са добавени 79 реда и са изтрити 69 реда
  1. 29 6
      cmd/lists.go
  2. 0 1
      frontend/package.json
  3. 1 1
      frontend/src/App.vue
  4. 10 8
      frontend/src/assets/style.scss
  5. 39 48
      frontend/src/views/Dashboard.vue
  6. 0 5
      frontend/yarn.lock

+ 29 - 6
cmd/lists.go

@@ -24,17 +24,18 @@ var (
 	listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
 )
 
-// handleGetLists handles retrieval of lists.
+// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
 func handleGetLists(c echo.Context) error {
 	var (
 		app = c.Get("app").(*App)
 		out listsWrap
 
-		pg        = getPagination(c.QueryParams(), 20)
-		orderBy   = c.FormValue("order_by")
-		order     = c.FormValue("order")
-		listID, _ = strconv.Atoi(c.Param("id"))
-		single    = false
+		pg         = getPagination(c.QueryParams(), 20)
+		orderBy    = c.FormValue("order_by")
+		order      = c.FormValue("order")
+		minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
+		listID, _  = strconv.Atoi(c.Param("id"))
+		single     = false
 	)
 
 	// Fetch one list.
@@ -42,6 +43,28 @@ func handleGetLists(c echo.Context) error {
 		single = true
 	}
 
+	if !single && minimal {
+		// Minimal query simply returns the list of all lists with no additional metadata. This is fast.
+		if err := app.queries.GetLists.Select(&out.Results, ""); err != nil {
+			app.log.Printf("error fetching lists: %v", err)
+			return echo.NewHTTPError(http.StatusInternalServerError,
+				app.i18n.Ts("globals.messages.errorFetching",
+					"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+		}
+		if len(out.Results) == 0 {
+			return c.JSON(http.StatusOK, okResp{[]struct{}{}})
+		}
+
+		// Meta.
+		out.Total = out.Results[0].Total
+		out.Page = 1
+		out.PerPage = out.Total
+		if out.PerPage == 0 {
+			out.PerPage = out.Total
+		}
+		return c.JSON(http.StatusOK, okResp{out})
+	}
+
 	// Sort params.
 	if !strSliceContains(orderBy, listQuerySortFields) {
 		orderBy = "created_at"

+ 0 - 1
frontend/package.json

@@ -22,7 +22,6 @@
     "textversionjs": "^1.1.3",
     "turndown": "^7.0.0",
     "vue": "^2.6.12",
-    "vue-c3": "^1.2.11",
     "vue-i18n": "^8.22.2",
     "vue-quill-editor": "^3.0.6",
     "vue-router": "^3.2.0",

+ 1 - 1
frontend/src/App.vue

@@ -191,7 +191,7 @@ export default Vue.extend({
   mounted() {
     // Lists is required across different views. On app load, fetch the lists
     // and have them in the store.
-    this.$api.getLists();
+    this.$api.getLists({ minimal: true });
   },
 });
 </script>

+ 10 - 8
frontend/src/assets/style.scss

@@ -64,9 +64,6 @@ section {
   &.wrap {
     max-width: 1100px;
   }
-  &.wrap-small {
-    max-width: 900px;
-  }
 }
 
 .spinner.is-tiny {
@@ -293,6 +290,12 @@ body.is-noscroll .b-sidebar {
 /* Fix for input colours */
 .button.is-primary {
   background: $primary;
+
+  &:not(.is-small) {
+    height: auto;
+    padding: 10px 20px;
+  }
+
   &:hover {
     background: darken($primary, 15%);
   }
@@ -336,11 +339,6 @@ body.is-noscroll .b-sidebar {
   }
 }
 
-.button {
-  height: auto;
-  padding: 10px 20px;
-}
-
 /* Form fields */
 .field {
   &:not(:last-child) {
@@ -732,6 +730,10 @@ section.campaign {
 
 /* C3 charting lib */
 .c3 {
+  .c3-text.c3-empty {
+    font-family: $body-family;
+    font-size: $size-6;
+  }
   .c3-chart-lines .c3-line {
     stroke-width: 2px;
   }

+ 39 - 48
frontend/src/views/Dashboard.vue

@@ -6,7 +6,7 @@
       </div>
     </header>
 
-    <section class="counts wrap-small">
+    <section class="counts wrap">
       <div class="tile is-ancestor">
         <div class="tile is-vertical is-12">
           <div class="tile">
@@ -15,13 +15,16 @@
               <article class="tile is-child notification" data-cy="lists">
                 <div class="columns is-mobile">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="format-list-bulleted-square" />
+                      {{ $utils.niceNumber(counts.lists.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.list', counts.lists.total) }}
                     </p>
                   </div>
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                         <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
                         {{ $t('lists.types.public') }}
@@ -46,13 +49,16 @@
               <article class="tile is-child notification" data-cy="campaigns">
                 <div class="columns is-mobile">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="rocket-launch-outline" />
+                      {{ $utils.niceNumber(counts.campaigns.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.campaign', counts.campaigns.total) }}
                     </p>
                   </div>
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
                         <label>{{ num }}</label> {{ status }}
                         <span v-if="status === 'running'" class="spinner is-tiny">
@@ -70,14 +76,17 @@
               <article class="tile is-child notification" data-cy="subscribers">
                 <div class="columns is-mobile">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="account-multiple" />
+                      {{ $utils.niceNumber(counts.subscribers.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
                     </p>
                   </div>
 
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                         <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
                         {{ $t('subscribers.status.blocklisted') }}
@@ -92,7 +101,10 @@
                 <hr />
                 <div class="columns" data-cy="messages">
                   <div class="column is-12">
-                    <p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
+                    <p class="title">
+                      <b-icon icon="email-outline" />
+                      {{ $utils.niceNumber(counts.messages) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                       {{ $t('dashboard.messagesSent') }}
                     </p>
@@ -107,15 +119,13 @@
               <div class="columns">
                 <div class="column is-6">
                   <h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
-                  <vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
-                  <empty-placeholder v-else-if="!isChartsLoading" />
+                  <div ref="chart-views"></div>
                 </div>
                 <div class="column is-6">
                   <h3 class="title is-size-6 has-text-right">
                     {{ $t('dashboard.linkClicks') }}
                   </h3><br />
-                  <vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
-                  <empty-placeholder v-else-if="!isChartsLoading" />
+                  <div ref="chart-clicks"></div>
                 </div>
               </div>
             </article>
@@ -133,23 +143,13 @@
 
 <script>
 import Vue from 'vue';
-import VueC3 from 'vue-c3';
+import c3 from 'c3';
 import dayjs from 'dayjs';
 import { colors } from '../constants';
-import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
 
 export default Vue.extend({
-  components: {
-    EmptyPlaceholder,
-    VueC3,
-  },
-
   data() {
     return {
-      // Unique Vue() instances for each chart.
-      chartViewsInst: null,
-      chartClicksInst: null,
-
       isChartsLoading: true,
       isCountsLoading: true,
 
@@ -163,21 +163,22 @@ export default Vue.extend({
   },
 
   methods: {
-    makeChart(label, data) {
+    renderChart(label, data, el) {
       const conf = {
+        bindto: el,
+        unload: true,
         data: {
-          columns: [
-            [label, ...data.map((d) => d.count).reverse()],
-          ],
           type: 'spline',
+          columns: [],
           color() {
             return colors.primary;
           },
+          empty: { label: { text: this.$t('globals.messages.emptyState') } },
         },
         axis: {
           x: {
             type: 'category',
-            categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
+            categories: data.map((d) => dayjs(d.date).format('DD MMM')),
             tick: {
               rotate: -45,
               multiline: false,
@@ -189,7 +190,14 @@ export default Vue.extend({
           show: false,
         },
       };
-      return conf;
+
+      if (data.length > 0) {
+        conf.data.columns.push([label, ...data.map((d) => d.count)]);
+      }
+
+      this.$nextTick(() => {
+        c3.generate(conf);
+      });
     },
   },
 
@@ -209,25 +217,8 @@ export default Vue.extend({
     // Pull the charts.
     this.$api.getDashboardCharts().then((data) => {
       this.isChartsLoading = false;
-
-      // vue-c3 lib requires unique instances of Vue() to communicate.
-      if (data.campaignViews.length > 0) {
-        this.chartViewsInst = this;
-
-        this.$nextTick(() => {
-          this.chartViewsInst.$emit('init',
-            this.makeChart(this.$t('dashboard.campaignViews'), data.campaignViews));
-        });
-      }
-
-      if (data.linkClicks.length > 0) {
-        this.chartClicksInst = new Vue();
-
-        this.$nextTick(() => {
-          this.chartClicksInst.$emit('init',
-            this.makeChart(this.$t('dashboard.linkClicks'), data.linkClicks));
-        });
-      }
+      this.renderChart(this.$t('dashboard.linkClicks'), data.campaignViews, this.$refs['chart-views']);
+      this.renderChart(this.$t('dashboard.linkClicks'), data.linkClicks, this.$refs['chart-clicks']);
     });
   },
 });

+ 0 - 5
frontend/yarn.lock

@@ -9991,11 +9991,6 @@ 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.6.0:
   version "7.6.0"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"