소스 검색

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"}
 	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 {
 func handleGetLists(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
 		out listsWrap
 		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.
 	// Fetch one list.
@@ -42,6 +43,28 @@ func handleGetLists(c echo.Context) error {
 		single = true
 		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.
 	// Sort params.
 	if !strSliceContains(orderBy, listQuerySortFields) {
 	if !strSliceContains(orderBy, listQuerySortFields) {
 		orderBy = "created_at"
 		orderBy = "created_at"

+ 0 - 1
frontend/package.json

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

+ 1 - 1
frontend/src/App.vue

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

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

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

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

@@ -6,7 +6,7 @@
       </div>
       </div>
     </header>
     </header>
 
 
-    <section class="counts wrap-small">
+    <section class="counts wrap">
       <div class="tile is-ancestor">
       <div class="tile is-ancestor">
         <div class="tile is-vertical is-12">
         <div class="tile is-vertical is-12">
           <div class="tile">
           <div class="tile">
@@ -15,13 +15,16 @@
               <article class="tile is-child notification" data-cy="lists">
               <article class="tile is-child notification" data-cy="lists">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <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">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.list', counts.lists.total) }}
                       {{ $tc('globals.terms.list', counts.lists.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
                   <div class="column is-6">
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                       <li>
                         <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
                         <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
                         {{ $t('lists.types.public') }}
                         {{ $t('lists.types.public') }}
@@ -46,13 +49,16 @@
               <article class="tile is-child notification" data-cy="campaigns">
               <article class="tile is-child notification" data-cy="campaigns">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <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">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.campaign', counts.campaigns.total) }}
                       {{ $tc('globals.terms.campaign', counts.campaigns.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
                   <div class="column is-6">
                   <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">
                       <li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
                         <label>{{ num }}</label> {{ status }}
                         <label>{{ num }}</label> {{ status }}
                         <span v-if="status === 'running'" class="spinner is-tiny">
                         <span v-if="status === 'running'" class="spinner is-tiny">
@@ -70,14 +76,17 @@
               <article class="tile is-child notification" data-cy="subscribers">
               <article class="tile is-child notification" data-cy="subscribers">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <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">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
                       {{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
 
 
                   <div class="column is-6">
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                       <li>
                         <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
                         <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
                         {{ $t('subscribers.status.blocklisted') }}
                         {{ $t('subscribers.status.blocklisted') }}
@@ -92,7 +101,10 @@
                 <hr />
                 <hr />
                 <div class="columns" data-cy="messages">
                 <div class="columns" data-cy="messages">
                   <div class="column is-12">
                   <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">
                     <p class="is-size-6 has-text-grey">
                       {{ $t('dashboard.messagesSent') }}
                       {{ $t('dashboard.messagesSent') }}
                     </p>
                     </p>
@@ -107,15 +119,13 @@
               <div class="columns">
               <div class="columns">
                 <div class="column is-6">
                 <div class="column is-6">
                   <h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
                   <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>
                 <div class="column is-6">
                 <div class="column is-6">
                   <h3 class="title is-size-6 has-text-right">
                   <h3 class="title is-size-6 has-text-right">
                     {{ $t('dashboard.linkClicks') }}
                     {{ $t('dashboard.linkClicks') }}
                   </h3><br />
                   </h3><br />
-                  <vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
-                  <empty-placeholder v-else-if="!isChartsLoading" />
+                  <div ref="chart-clicks"></div>
                 </div>
                 </div>
               </div>
               </div>
             </article>
             </article>
@@ -133,23 +143,13 @@
 
 
 <script>
 <script>
 import Vue from 'vue';
 import Vue from 'vue';
-import VueC3 from 'vue-c3';
+import c3 from 'c3';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
 import { colors } from '../constants';
 import { colors } from '../constants';
-import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
 
 
 export default Vue.extend({
 export default Vue.extend({
-  components: {
-    EmptyPlaceholder,
-    VueC3,
-  },
-
   data() {
   data() {
     return {
     return {
-      // Unique Vue() instances for each chart.
-      chartViewsInst: null,
-      chartClicksInst: null,
-
       isChartsLoading: true,
       isChartsLoading: true,
       isCountsLoading: true,
       isCountsLoading: true,
 
 
@@ -163,21 +163,22 @@ export default Vue.extend({
   },
   },
 
 
   methods: {
   methods: {
-    makeChart(label, data) {
+    renderChart(label, data, el) {
       const conf = {
       const conf = {
+        bindto: el,
+        unload: true,
         data: {
         data: {
-          columns: [
-            [label, ...data.map((d) => d.count).reverse()],
-          ],
           type: 'spline',
           type: 'spline',
+          columns: [],
           color() {
           color() {
             return colors.primary;
             return colors.primary;
           },
           },
+          empty: { label: { text: this.$t('globals.messages.emptyState') } },
         },
         },
         axis: {
         axis: {
           x: {
           x: {
             type: 'category',
             type: 'category',
-            categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
+            categories: data.map((d) => dayjs(d.date).format('DD MMM')),
             tick: {
             tick: {
               rotate: -45,
               rotate: -45,
               multiline: false,
               multiline: false,
@@ -189,7 +190,14 @@ export default Vue.extend({
           show: false,
           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.
     // Pull the charts.
     this.$api.getDashboardCharts().then((data) => {
     this.$api.getDashboardCharts().then((data) => {
       this.isChartsLoading = false;
       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"
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
   integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
   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:
 vue-eslint-parser@^7.6.0:
   version "7.6.0"
   version "7.6.0"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"