From 957bdd5c19bbcae77f83b63fb088c05a98b195b7 Mon Sep 17 00:00:00 2001
From: Dmitriy Koltsov <dkoltsov@picodata.io>
Date: Tue, 13 Aug 2024 17:43:20 +0300
Subject: [PATCH] feat(webui/plugins): display plagins in WebUI

Add services to each tier and list of installed
plugins to cluster info

Closes #809
---
 src/http_server.rs                            |  21 ++-
 src/storage.rs                                |  27 ++-
 test/int/test_http_server.py                  | 166 +++++++++++++++++-
 .../ClusterInfo/ClusterInfo.module.scss       |   4 +
 .../nodesPage/ClusterInfo/ClusterInfo.tsx     |  19 ++
 .../TierCard/TierCard.module.scss             |   4 +-
 .../NodesContent/TierCard/TierCard.tsx        |  21 ++-
 webui/src/shared/entity/cluster/info/types.ts |   1 +
 webui/src/shared/entity/tier/common/types.ts  |   2 +-
 webui/src/shared/entity/tier/list/mock.ts     |   4 +-
 .../intl/translations/en/pages/instances.ts   |   7 +-
 .../intl/translations/ru/pages/instances.ts   |   7 +-
 12 files changed, 260 insertions(+), 23 deletions(-)

diff --git a/src/http_server.rs b/src/http_server.rs
index f8adf82139..b4644a837f 100644
--- a/src/http_server.rs
+++ b/src/http_server.rs
@@ -138,7 +138,7 @@ pub(crate) struct TierInfo {
     #[serde(rename = "can_vote")] // for compatibility with lua version
     can_vote: bool,
     name: String,
-    plugins: Vec<String>,
+    services: Vec<String>,
 }
 
 // From the Lua version:
@@ -168,6 +168,8 @@ pub(crate) struct ClusterInfo {
     instances_current_state_offline: usize,
     memory: MemoryInfo,
     instances_current_state_online: usize,
+    // list of serialized plugin identifiers - "<plugin_name> <plugin_version>"
+    plugins: Vec<String>,
 }
 
 fn get_replicasets(
@@ -365,6 +367,14 @@ pub(crate) fn http_api_cluster() -> Result<ClusterInfo, Box<dyn Error>> {
     let storage = Clusterwide::get();
     let replicasets = get_replicasets_info(storage, true)?;
 
+    let plugins = storage
+        .plugin
+        .get_all()
+        .expect("storage shouldn't fail")
+        .iter()
+        .map(|plugin| [plugin.name.clone(), plugin.version.clone()].join(" "))
+        .collect();
+
     let mut instances = 0;
     let mut instances_online = 0;
     let mut replicasets_count = 0;
@@ -395,6 +405,7 @@ pub(crate) fn http_api_cluster() -> Result<ClusterInfo, Box<dyn Error>> {
         instances_current_state_offline: (instances - instances_online),
         memory: mem_info,
         instances_current_state_online: instances_online,
+        plugins,
     };
 
     Ok(res)
@@ -416,7 +427,13 @@ pub(crate) fn http_api_tiers() -> Result<Vec<TierInfo>, Box<dyn Error>> {
                     instance_count: 0,
                     can_vote: true,
                     name: item.name.clone(),
-                    plugins: Vec::new(),
+                    services: storage
+                        .service
+                        .get_by_tier(&item.name)
+                        .expect("storage shouldn't fail")
+                        .iter()
+                        .map(|service| service.name.clone())
+                        .collect(),
                 },
             )
         })
diff --git a/src/storage.rs b/src/storage.rs
index 61f77ee12e..6f776ce116 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -3627,12 +3627,18 @@ impl Plugins {
 
     #[inline]
     pub fn get_all_versions(&self, plugin_name: &str) -> tarantool::Result<Vec<PluginDef>> {
-        let plugins = self
-            .space
+        self.space
             .select(IteratorType::All, &(plugin_name,))?
             .map(|tuple| tuple.decode())
-            .collect::<tarantool::Result<Vec<_>>>();
-        plugins
+            .collect()
+    }
+
+    #[inline]
+    pub fn get_all(&self) -> tarantool::Result<Vec<PluginDef>> {
+        self.space
+            .select(IteratorType::All, &())?
+            .map(|tuple| tuple.decode())
+            .collect::<tarantool::Result<Vec<_>>>()
     }
 
     #[inline]
@@ -3726,6 +3732,19 @@ impl Services {
         Ok(result)
     }
 
+    #[inline]
+    pub fn get_by_tier(&self, tier_name: &String) -> tarantool::Result<Vec<ServiceDef>> {
+        let all_services = self.space.select(IteratorType::All, &())?;
+        let mut result = vec![];
+        for tuple in all_services {
+            let svc = tuple.decode::<ServiceDef>()?;
+            if svc.tiers.contains(tier_name) {
+                result.push(svc)
+            }
+        }
+        Ok(result)
+    }
+
     #[inline]
     pub fn delete(&self, plugin: &str, service: &str, version: &str) -> tarantool::Result<()> {
         self.space.delete(&[plugin, service, version])?;
diff --git a/test/int/test_http_server.py b/test/int/test_http_server.py
index 702ca4a311..856b0e7463 100644
--- a/test/int/test_http_server.py
+++ b/test/int/test_http_server.py
@@ -1,4 +1,12 @@
-from conftest import Instance
+from conftest import (
+    Cluster,
+    Instance,
+    _PLUGIN,
+    _PLUGIN_SERVICES,
+    _PLUGIN_SMALL,
+    _PLUGIN_SMALL_SERVICES,
+    _PLUGIN_VERSION_1,
+)
 from urllib.request import urlopen
 import pytest
 import json
@@ -23,7 +31,7 @@ def test_http_routes(instance: Instance):
 
 
 @pytest.mark.webui
-def test_webui(instance: Instance):
+def test_webui_basic(instance: Instance):
     http_listen = instance.env["PICODATA_HTTP_LISTEN"]
 
     instance_version = instance.eval("return pico.PICODATA_VERSION")
@@ -66,7 +74,7 @@ def test_webui(instance: Instance):
                 "instanceCount": 1,
                 "can_vote": True,
                 "name": "default",
-                "plugins": [],
+                "services": [],
             }
         ]
 
@@ -79,6 +87,158 @@ def test_webui(instance: Instance):
             "currentInstaceVersion": instance_version,
             "memory": {"usable": 67108864, "used": 33554432},
             "instancesCurrentStateOnline": 1,
+            "plugins": [],
+        }
+
+
+@pytest.mark.webui
+def test_webui_with_plugin(cluster: Cluster):
+    cluster_cfg = """
+    cluster:
+        cluster_id: test
+        tier:
+            red:
+                replication_factor: 1
+            blue:
+                replication_factor: 1
+            green:
+                replication_factor: 1
+    """
+    cluster.set_config_file(yaml=cluster_cfg)
+
+    i1 = cluster.add_instance(wait_online=True, tier="red", enable_http=True)
+    i2 = cluster.add_instance(wait_online=True, tier="blue")
+    i3 = cluster.add_instance(wait_online=True, tier="green")
+
+    i1.call("pico.install_plugin", _PLUGIN, _PLUGIN_VERSION_1)
+    i1.call("pico.install_plugin", _PLUGIN_SMALL, _PLUGIN_VERSION_1)
+    i1.call(
+        "pico.service_append_tier",
+        _PLUGIN,
+        _PLUGIN_VERSION_1,
+        _PLUGIN_SERVICES[0],
+        "red",
+    )
+    i1.call(
+        "pico.service_append_tier",
+        _PLUGIN,
+        _PLUGIN_VERSION_1,
+        _PLUGIN_SERVICES[1],
+        "blue",
+    )
+    i1.call(
+        "pico.service_append_tier",
+        _PLUGIN_SMALL,
+        _PLUGIN_VERSION_1,
+        _PLUGIN_SMALL_SERVICES[0],
+        "blue",
+    )
+
+    http_listen = i1.env["PICODATA_HTTP_LISTEN"]
+    instance_version = i1.eval("return pico.PICODATA_VERSION")
+
+    with urlopen(f"http://{http_listen}/") as response:
+        assert response.headers.get("content-type") == "text/html"
+
+    instance_template = {
+        "failureDomain": {},
+        "isLeader": True,
+        "currentState": "Online",
+        "targetState": "Online",
+        "version": instance_version,
+    }
+    instance_1 = {
+        **instance_template,
+        "name": "i1",
+        "binaryAddress": i1.listen,
+        "httpAddress": http_listen,
+    }
+    instance_2 = {
+        **instance_template,
+        "name": "i2",
+        "binaryAddress": i2.listen,
+        "httpAddress": "",
+    }
+    instance_3 = {
+        **instance_template,
+        "name": "i3",
+        "binaryAddress": i3.listen,
+        "httpAddress": "",
+    }
+
+    replicaset_template = {
+        "state": "Online",
+        "version": instance_version,
+        "instanceCount": 1,
+        "capacityUsage": 50,
+        "memory": {
+            "usable": 67108864,
+            "used": 33554432,
+        },
+        "uuid": i1.replicaset_uuid(),
+        "id": "r1",
+    }
+    r1 = {
+        **replicaset_template,
+        "uuid": i1.replicaset_uuid(),
+        "id": "r1",
+        "instances": [instance_1],
+    }
+    r2 = {
+        **replicaset_template,
+        "uuid": i2.replicaset_uuid(),
+        "id": "r2",
+        "instances": [instance_2],
+    }
+    r3 = {
+        **replicaset_template,
+        "uuid": i3.replicaset_uuid(),
+        "id": "r3",
+        "instances": [instance_3],
+    }
+
+    tier_template = {
+        "replicasetCount": 1,
+        "rf": 1,
+        "instanceCount": 1,
+        "can_vote": True,
+    }
+
+    tier_red = {
+        **tier_template,
+        "name": "red",
+        "services": [_PLUGIN_SERVICES[0]],
+        "replicasets": [r1],
+    }
+    tier_blue = {
+        **tier_template,
+        "name": "blue",
+        "services": [_PLUGIN_SERVICES[1], _PLUGIN_SMALL_SERVICES[0]],
+        "replicasets": [r2],
+    }
+    tier_green = {**tier_template, "name": "green", "services": [], "replicasets": [r3]}
+
+    with urlopen(f"http://{http_listen}/api/v1/tiers") as response:
+        assert response.headers.get("content-type") == "application/json"
+        assert sorted(json.load(response), key=lambda tier: tier["name"]) == [
+            tier_blue,
+            tier_green,
+            tier_red,
+        ]
+
+    with urlopen(f"http://{http_listen}/api/v1/cluster") as response:
+        assert response.headers.get("content-type") == "application/json"
+        assert json.load(response) == {
+            "capacityUsage": 50,
+            "replicasetsCount": 3,
+            "instancesCurrentStateOnline": 3,
+            "instancesCurrentStateOffline": 0,
+            "currentInstaceVersion": instance_version,
+            "memory": {"usable": 201326592, "used": 100663296},
+            "plugins": [
+                _PLUGIN + " " + _PLUGIN_VERSION_1,
+                _PLUGIN_SMALL + " " + _PLUGIN_VERSION_1,
+            ],
         }
 
 
diff --git a/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.module.scss b/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.module.scss
index dd34919954..7c647d2fd5 100644
--- a/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.module.scss
+++ b/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.module.scss
@@ -24,6 +24,10 @@
   display: flex;
 }
 
+.pluginsWrapper {
+  display: flex;
+}
+
 .columnName {
   font-size: 16px;
   font-weight: 500;
diff --git a/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.tsx b/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.tsx
index 35eb26caf7..91312e918b 100644
--- a/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.tsx
+++ b/webui/src/modules/nodes/nodesPage/ClusterInfo/ClusterInfo.tsx
@@ -41,6 +41,25 @@ export const ClusterInfo = (props: ClusterInfoProps) => {
         </div>
       </div>
       <div className={styles.right}>
+        <div className={cn(styles.rightColumn)}>
+          <div className={styles.columnName}>
+            {clusterTranslations.plugins.label}
+          </div>
+          <div className={styles.columnContent}>
+            <div className={styles.columnValue}>
+              {clusterInfoData.plugins.map((plugin, i) =>
+                i == 0 ? (
+                  <>{plugin}</>
+                ) : (
+                  <>
+                    <br />
+                    {plugin}
+                  </>
+                )
+              )}
+            </div>
+          </div>
+        </div>
         <div className={cn(styles.rightColumn)}>
           <div className={styles.columnName}>
             {clusterTranslations.replicasets.label}
diff --git a/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.module.scss b/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.module.scss
index 1ae0b185c2..58671e3017 100644
--- a/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.module.scss
+++ b/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.module.scss
@@ -50,7 +50,7 @@ $chevron-column-flex: 0.2;
   align-items: flex-start;
 }
 
-.pluginColumn {
+.servicesColumn {
   flex: 1 167px;
 }
 
@@ -100,7 +100,7 @@ $chevron-column-flex: 0.2;
   @include ellipsis;
 }
 
-.pluginValue {
+.servicesValue {
   text-align: center;
 }
 
diff --git a/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.tsx b/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.tsx
index 50ec1afae2..25776faece 100644
--- a/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.tsx
+++ b/webui/src/modules/nodes/nodesPage/NodesContent/TierCard/TierCard.tsx
@@ -46,20 +46,31 @@ export const TierCard: FC<TierCardProps> = React.memo(({ tier }) => {
         <div
           className={cn(
             styles.infoColumn,
-            styles.pluginColumn,
+            styles.servicesColumn,
             styles.hiddenColumn
           )}
         >
-          <div className={styles.label}>{tierTranslations.plugins.label}</div>
+          <div className={styles.label}>{tierTranslations.services.label}</div>
           <div
             className={cn(
               styles.infoValue,
               styles.hiddenValue,
-              styles.pluginValue
+              styles.servicesValue
             )}
           >
-            {tier.plugins.length ? (
-              <HiddenWrapper>{tier.plugins.join(", ")}</HiddenWrapper>
+            {tier.services.length ? (
+              <HiddenWrapper>
+                {tier.services.map((service, i) =>
+                  i == 0 ? (
+                    <>{service}</>
+                  ) : (
+                    <>
+                      <br />
+                      {service}
+                    </>
+                  )
+                )}
+              </HiddenWrapper>
             ) : (
               <InfoNoData text={translation.components.infoNoData.label} />
             )}
diff --git a/webui/src/shared/entity/cluster/info/types.ts b/webui/src/shared/entity/cluster/info/types.ts
index 4e51e2c7d9..d733659649 100644
--- a/webui/src/shared/entity/cluster/info/types.ts
+++ b/webui/src/shared/entity/cluster/info/types.ts
@@ -8,6 +8,7 @@ export type ServerClusterInfoType = {
   instancesCurrentStateOnline: number;
   instancesCurrentStateOffline: number;
   currentInstaceVersion: string;
+  plugins: string[];
 };
 
 export type ClusterInfoType = ServerClusterInfoType;
diff --git a/webui/src/shared/entity/tier/common/types.ts b/webui/src/shared/entity/tier/common/types.ts
index 7e19abce39..3d9a157d7e 100644
--- a/webui/src/shared/entity/tier/common/types.ts
+++ b/webui/src/shared/entity/tier/common/types.ts
@@ -6,7 +6,7 @@ import {
 
 export type ServerTierType = {
   name: string;
-  plugins: string[];
+  services: string[];
   replicasetCount: number;
   instanceCount: number;
   rf: number;
diff --git a/webui/src/shared/entity/tier/list/mock.ts b/webui/src/shared/entity/tier/list/mock.ts
index df61a02c96..b223cc70ac 100644
--- a/webui/src/shared/entity/tier/list/mock.ts
+++ b/webui/src/shared/entity/tier/list/mock.ts
@@ -84,7 +84,7 @@ export const mock: ServerTiersListType = [
     instanceCount: 4,
     can_vote: true,
     name: "red",
-    plugins: [],
+    services: [],
   },
   {
     replicasets: [
@@ -144,6 +144,6 @@ export const mock: ServerTiersListType = [
     instanceCount: 2,
     can_vote: true,
     name: "blue",
-    plugins: [],
+    services: [],
   },
 ];
diff --git a/webui/src/shared/intl/translations/en/pages/instances.ts b/webui/src/shared/intl/translations/en/pages/instances.ts
index daf322fad1..e5e95b39c9 100644
--- a/webui/src/shared/intl/translations/en/pages/instances.ts
+++ b/webui/src/shared/intl/translations/en/pages/instances.ts
@@ -1,5 +1,8 @@
 export const instances = {
   cluster: {
+    plugins: {
+      label: "Plugins",
+    },
     capacityProgress: {
       label: "Capacity Usage",
       valueLabel: "Useful capacity",
@@ -57,8 +60,8 @@ export const instances = {
       name: {
         label: "Tier Name",
       },
-      plugins: {
-        label: "Plugins",
+      services: {
+        label: "Services",
       },
       replicasets: {
         label: "Replicasets",
diff --git a/webui/src/shared/intl/translations/ru/pages/instances.ts b/webui/src/shared/intl/translations/ru/pages/instances.ts
index 187258c467..56da1d5a92 100644
--- a/webui/src/shared/intl/translations/ru/pages/instances.ts
+++ b/webui/src/shared/intl/translations/ru/pages/instances.ts
@@ -2,6 +2,9 @@ import { TPages } from "./types";
 
 export const instances: TPages["instances"] = {
   cluster: {
+    plugins: {
+      label: "Плагины",
+    },
     capacityProgress: {
       label: "Потребление памяти",
       valueLabel: "Использовано",
@@ -59,8 +62,8 @@ export const instances: TPages["instances"] = {
       name: {
         label: "Название тира",
       },
-      plugins: {
-        label: "Плагин",
+      services: {
+        label: "Сервисы",
       },
       replicasets: {
         label: "Репликасеты",
-- 
GitLab