From e699246d87a28f38dae2b1bb756a8c6778f45cca Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Wed, 3 Jul 2024 12:23:52 +0000
Subject: [PATCH] limit number of parallel requests and cache gained info,
 fixes #4358

Closes #4358

Merge request studip/studip!3161
---
 .../javascripts/lib/chunked-requester.ts      | 76 +++++++++++++++++++
 .../vue/components/tree/StudipTreeList.vue    |  2 +-
 .../vue/components/tree/StudipTreeTable.vue   |  2 +-
 .../components/tree/TreeNodeCourseInfo.vue    | 12 +--
 resources/vue/mixins/TreeMixin.js             | 23 +++++-
 5 files changed, 104 insertions(+), 11 deletions(-)
 create mode 100644 resources/assets/javascripts/lib/chunked-requester.ts

diff --git a/resources/assets/javascripts/lib/chunked-requester.ts b/resources/assets/javascripts/lib/chunked-requester.ts
new file mode 100644
index 00000000000..6f53c010fa9
--- /dev/null
+++ b/resources/assets/javascripts/lib/chunked-requester.ts
@@ -0,0 +1,76 @@
+import axios from "axios";
+
+interface ChunkedRequest {
+    url: string,
+    parameters: object,
+    resolve(value: any): any,
+    reject(): any,
+}
+
+export default class ChunkedRequester
+{
+    #requests: ChunkedRequest[] = [];
+
+    readonly #delay: number;
+    readonly #limit: number;
+    #timeout: any = null;
+
+    constructor(limit: number = 16, delay: number = 500) {
+        if (limit < 1) {
+            throw new Error('Limit must be positive');
+        }
+
+        this.#limit = limit;
+        this.#delay = delay;
+    }
+
+    addRequest(url: string, parameters: object = {}): Promise<any>
+    {
+        return new Promise((resolve, reject) => {
+            this.#requests.push({
+                url,
+                parameters,
+                resolve,
+                reject
+            });
+            this.#startRequests();
+        });
+    }
+
+    #startRequests(): void
+    {
+        if (this.#requests.length === 0) {
+            return;
+        }
+
+        if (this.#requests.length < this.#limit) {
+            this.clearTimeout();
+        }
+
+        if (this.#timeout !== null) {
+            return;
+        }
+
+        this.#timeout = setTimeout(
+            () => {
+                Promise.all(
+                    this.#requests
+                        .splice(0, this.#limit)
+                        .map(({url, parameters, resolve, reject}) => {
+                            return axios.get(url, {params: parameters}).then(resolve, reject);
+                        })
+                ).then(() => {
+                    this.clearTimeout();
+                    this.#startRequests();
+                });
+            }
+            , this.#delay
+        );
+    }
+
+    clearTimeout(): void
+    {
+        clearTimeout(this.#timeout);
+        this.#timeout = null;
+    }
+}
diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue
index 6214234c3e9..155503bd947 100644
--- a/resources/vue/components/tree/StudipTreeList.vue
+++ b/resources/vue/components/tree/StudipTreeList.vue
@@ -222,7 +222,7 @@ export default {
             courses: [],
             assistiveLive: '',
             subLevelsCourses: 0,
-            thisLevelCourses: 0,
+            thisLevelCourses: this.getCachedNodeCourseInfo(this.node.id, this.semester, this.semClass),
             showingAllCourses: false
         }
     },
diff --git a/resources/vue/components/tree/StudipTreeTable.vue b/resources/vue/components/tree/StudipTreeTable.vue
index 1dc45a0166e..17030250242 100644
--- a/resources/vue/components/tree/StudipTreeTable.vue
+++ b/resources/vue/components/tree/StudipTreeTable.vue
@@ -245,7 +245,7 @@ export default {
             courses: [],
             assistiveLive: '',
             subLevelsCourses: 0,
-            thisLevelCourses: 0,
+            thisLevelCourses: this.getCachedNodeCourseInfo(this.node.id, this.semester, this.semClass),
             showingAllCourses: false
         }
     },
diff --git a/resources/vue/components/tree/TreeNodeCourseInfo.vue b/resources/vue/components/tree/TreeNodeCourseInfo.vue
index 3f3777c9338..859429e7652 100644
--- a/resources/vue/components/tree/TreeNodeCourseInfo.vue
+++ b/resources/vue/components/tree/TreeNodeCourseInfo.vue
@@ -32,22 +32,24 @@ export default {
     },
     data() {
         return {
-            isLoading: false,
-            courseCount: 0,
+            courseCount: this.getCachedNodeCourseInfo(this.node, this.semester, this.semClass),
             showingAllCourses: false
         }
     },
+    computed: {
+        isLoading() {
+            return this.courseCount === null;
+        }
+    },
     methods: {
         showAllCourses(state) {
             this.showingAllCourses = state;
             this.$emit('showAllCourses', state);
         },
         loadNodeInfo(node) {
-            this.isLoading = true;
             this.getNodeCourseInfo(node, this.semester, this.semClass)
                 .then(info => {
-                    this.courseCount = info?.data.courses;
-                    this.isLoading = false;
+                    this.courseCount = info?.data.courses ?? 0;
                 });
         }
     },
diff --git a/resources/vue/mixins/TreeMixin.js b/resources/vue/mixins/TreeMixin.js
index 4263eaf938d..1269ffa51c0 100644
--- a/resources/vue/mixins/TreeMixin.js
+++ b/resources/vue/mixins/TreeMixin.js
@@ -1,4 +1,9 @@
 import axios from 'axios';
+import ChunkedRequester from '@/assets/javascripts/lib/chunked-requester';
+import Cache from '@/assets/javascripts/lib/cache';
+
+const requester = new ChunkedRequester();
+const cache = Cache.getInstance('tree-info/');
 
 export const TreeMixin = {
     data() {
@@ -56,7 +61,10 @@ export const TreeMixin = {
                 {params: parameters}
             );
         },
-        async getNodeCourseInfo(node, semesterId, semClass = 0) {
+        getCachedNodeCourseInfo(node, semesterId, semClass) {
+            return cache.get(['course-info', node.id, semesterId, semClass].join('/')) ?? null;
+        },
+        getNodeCourseInfo(node, semesterId, semClass = 0) {
             let parameters = {};
 
             if (semesterId !== 'all' && semesterId !== '0') {
@@ -67,10 +75,17 @@ export const TreeMixin = {
                 parameters['filter[semclass]'] = semClass;
             }
 
-            return axios.get(
+            return requester.addRequest(
                 STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/courseinfo'),
-                { params: parameters }
-            );
+                parameters
+            ).then(courseinfo => {
+                cache.set(
+                    ['course-info', node.id, semesterId, semClass].join('/'),
+                    courseinfo.data.courses ?? 0,
+                    3 * 60 * 60
+                );
+                return courseinfo;
+            });
         },
         nodeUrl(node_id, semester = null ) {
             return STUDIP.URLHelper.getURL('', { node_id, semester })
-- 
GitLab