From be8363cc3514749f54c5060c6548138129612e82 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Wed, 27 Nov 2024 17:01:47 -0800
Subject: [PATCH] Feature: nested groups (#4346)

---
 src/components/services/group.jsx        | 33 ++++++++--
 src/components/services/item.jsx         |  6 +-
 src/components/services/list.jsx         |  4 +-
 src/components/services/ping.jsx         |  4 +-
 src/components/services/site-monitor.jsx |  4 +-
 src/pages/api/ping.js                    |  6 +-
 src/pages/api/siteMonitor.js             |  6 +-
 src/pages/index.jsx                      |  6 +-
 src/utils/config/api-response.js         | 24 +++++--
 src/utils/config/service-helpers.js      | 81 ++++++++++++++++--------
 10 files changed, 119 insertions(+), 55 deletions(-)

diff --git a/src/components/services/group.jsx b/src/components/services/group.jsx
index cdbb89f3..f25f7ec1 100644
--- a/src/components/services/group.jsx
+++ b/src/components/services/group.jsx
@@ -3,12 +3,13 @@ import classNames from "classnames";
 import { Disclosure, Transition } from "@headlessui/react";
 import { MdKeyboardArrowDown } from "react-icons/md";
 
+import { columnMap } from "../../utils/layout/columns";
+
 import List from "components/services/list";
 import ResolvedIcon from "components/resolvedicon";
 
 export default function ServicesGroup({
   group,
-  services,
   layout,
   fiveColumns,
   disableCollapse,
@@ -23,7 +24,7 @@ export default function ServicesGroup({
 
   return (
     <div
-      key={services.name}
+      key={group.name}
       className={classNames(
         "services-group",
         layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4",
@@ -42,7 +43,7 @@ export default function ServicesGroup({
                   </div>
                 )}
                 <h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name">
-                  {services.name}
+                  {group.name}
                 </h2>
                 <MdKeyboardArrowDown
                   className={classNames(
@@ -74,7 +75,31 @@ export default function ServicesGroup({
               }}
             >
               <Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static>
-                <List group={group} services={services.services} layout={layout} useEqualHeights={useEqualHeights} />
+                <List
+                  groupName={group.name}
+                  services={group.services}
+                  layout={layout}
+                  useEqualHeights={useEqualHeights}
+                />
+                {group.groups?.length > 0 && (
+                  <div
+                    className={`grid ${
+                      layout?.style === "row" ? `grid ${columnMap[layout?.columns]} gap-x-2` : "flex flex-col"
+                    } gap-2`}
+                  >
+                    {group.groups.map((subgroup) => (
+                      <ServicesGroup
+                        key={subgroup.name}
+                        group={subgroup}
+                        layout={layout?.[subgroup.name]}
+                        fiveColumns={fiveColumns}
+                        disableCollapse={disableCollapse}
+                        useEqualHeights={useEqualHeights}
+                        groupsInitiallyCollapsed={groupsInitiallyCollapsed}
+                      />
+                    ))}
+                  </div>
+                )}
               </Disclosure.Panel>
             </Transition>
           </>
diff --git a/src/components/services/item.jsx b/src/components/services/item.jsx
index 54560d6f..adf5fc97 100644
--- a/src/components/services/item.jsx
+++ b/src/components/services/item.jsx
@@ -12,7 +12,7 @@ import Kubernetes from "widgets/kubernetes/component";
 import { SettingsContext } from "utils/contexts/settings";
 import ResolvedIcon from "components/resolvedicon";
 
-export default function Item({ service, group, useEqualHeights }) {
+export default function Item({ service, groupName, useEqualHeights }) {
   const hasLink = service.href && service.href !== "#";
   const { settings } = useContext(SettingsContext);
   const showStats = service.showStats === false ? false : settings.showStats;
@@ -90,14 +90,14 @@ export default function Item({ service, group, useEqualHeights }) {
           >
             {service.ping && (
               <div className="flex-shrink-0 flex items-center justify-center service-tag service-ping">
-                <Ping group={group} service={service.name} style={statusStyle} />
+                <Ping groupName={groupName} serviceName={service.name} style={statusStyle} />
                 <span className="sr-only">Ping status</span>
               </div>
             )}
 
             {service.siteMonitor && (
               <div className="flex-shrink-0 flex items-center justify-center service-tag service-site-monitor">
-                <SiteMonitor group={group} service={service.name} style={statusStyle} />
+                <SiteMonitor groupName={groupName} serviceName={service.name} style={statusStyle} />
                 <span className="sr-only">Site monitor status</span>
               </div>
             )}
diff --git a/src/components/services/list.jsx b/src/components/services/list.jsx
index f3fd6e2a..c15d6aed 100644
--- a/src/components/services/list.jsx
+++ b/src/components/services/list.jsx
@@ -4,7 +4,7 @@ import { columnMap } from "../../utils/layout/columns";
 
 import Item from "components/services/item";
 
-export default function List({ group, services, layout, useEqualHeights }) {
+export default function List({ groupName, services, layout, useEqualHeights }) {
   return (
     <ul
       className={classNames(
@@ -16,7 +16,7 @@ export default function List({ group, services, layout, useEqualHeights }) {
         <Item
           key={[service.container, service.app, service.name].filter((s) => s).join("-")}
           service={service}
-          group={group}
+          groupName={groupName}
           useEqualHeights={layout?.useEqualHeights ?? useEqualHeights}
         />
       ))}
diff --git a/src/components/services/ping.jsx b/src/components/services/ping.jsx
index f72d40b3..670f9d4b 100644
--- a/src/components/services/ping.jsx
+++ b/src/components/services/ping.jsx
@@ -1,9 +1,9 @@
 import { useTranslation } from "react-i18next";
 import useSWR from "swr";
 
-export default function Ping({ group, service, style }) {
+export default function Ping({ groupName, serviceName, style }) {
   const { t } = useTranslation();
-  const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ group, service }).toString()}`, {
+  const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ groupName, serviceName }).toString()}`, {
     refreshInterval: 30000,
   });
 
diff --git a/src/components/services/site-monitor.jsx b/src/components/services/site-monitor.jsx
index 3d5ef79e..4dceb44c 100644
--- a/src/components/services/site-monitor.jsx
+++ b/src/components/services/site-monitor.jsx
@@ -1,9 +1,9 @@
 import { useTranslation } from "react-i18next";
 import useSWR from "swr";
 
-export default function SiteMonitor({ group, service, style }) {
+export default function SiteMonitor({ groupName, serviceName, style }) {
   const { t } = useTranslation();
-  const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ group, service }).toString()}`, {
+  const { data, error } = useSWR(`/api/siteMonitor?${new URLSearchParams({ groupName, serviceName }).toString()}`, {
     refreshInterval: 30000,
   });
 
diff --git a/src/pages/api/ping.js b/src/pages/api/ping.js
index e540fa68..8ef64ffc 100644
--- a/src/pages/api/ping.js
+++ b/src/pages/api/ping.js
@@ -6,10 +6,10 @@ import createLogger from "utils/logger";
 const logger = createLogger("ping");
 
 export default async function handler(req, res) {
-  const { group, service } = req.query;
-  const serviceItem = await getServiceItem(group, service);
+  const { groupName, serviceName } = req.query;
+  const serviceItem = await getServiceItem(groupName, serviceName);
   if (!serviceItem) {
-    logger.debug(`No service item found for group ${group} named ${service}`);
+    logger.debug(`No service item found for group ${groupName} named ${serviceName}`);
     return res.status(400).send({
       error: "Unable to find service, see log for details.",
     });
diff --git a/src/pages/api/siteMonitor.js b/src/pages/api/siteMonitor.js
index 9e030d74..072d3d4c 100644
--- a/src/pages/api/siteMonitor.js
+++ b/src/pages/api/siteMonitor.js
@@ -7,10 +7,10 @@ import { httpProxy } from "utils/proxy/http";
 const logger = createLogger("siteMonitor");
 
 export default async function handler(req, res) {
-  const { group, service } = req.query;
-  const serviceItem = await getServiceItem(group, service);
+  const { groupName, serviceName } = req.query;
+  const serviceItem = await getServiceItem(groupName, serviceName);
   if (!serviceItem) {
-    logger.debug(`No service item found for group ${group} named ${service}`);
+    logger.debug(`No service item found for group ${groupName} named ${serviceName}`);
     return res.status(400).send({
       error: "Unable to find service, see log for details.",
     });
diff --git a/src/pages/index.jsx b/src/pages/index.jsx
index dd0df95f..7a7fdef0 100644
--- a/src/pages/index.jsx
+++ b/src/pages/index.jsx
@@ -291,8 +291,7 @@ function Home({ initialSettings }) {
               group.services ? (
                 <ServicesGroup
                   key={group.name}
-                  group={group.name}
-                  services={group}
+                  group={group}
                   layout={settings.layout?.[group.name]}
                   fiveColumns={settings.fiveColumns}
                   disableCollapse={settings.disableCollapse}
@@ -316,8 +315,7 @@ function Home({ initialSettings }) {
             {serviceGroups.map((group) => (
               <ServicesGroup
                 key={group.name}
-                group={group.name}
-                services={group}
+                group={group}
                 layout={settings.layout?.[group.name]}
                 fiveColumns={settings.fiveColumns}
                 disableCollapse={settings.disableCollapse}
diff --git a/src/utils/config/api-response.js b/src/utils/config/api-response.js
index 97f61ea0..5f40d626 100644
--- a/src/utils/config/api-response.js
+++ b/src/utils/config/api-response.js
@@ -10,6 +10,7 @@ import {
   servicesFromDocker,
   cleanServiceGroups,
   servicesFromKubernetes,
+  findGroupByName,
 } from "utils/config/service-helpers";
 import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
 
@@ -84,6 +85,17 @@ export async function widgetsResponse() {
   return configuredWidgets;
 }
 
+function mergeSubgroups(configuredGroups, mergedGroup) {
+  configuredGroups.forEach((group) => {
+    if (group.name === mergedGroup.name) {
+      // eslint-disable-next-line no-param-reassign
+      group.services = mergedGroup.services;
+    } else if (group.groups) {
+      mergeSubgroups(group.groups, mergedGroup);
+    }
+  });
+}
+
 export async function servicesResponse() {
   let discoveredDockerServices;
   let discoveredKubernetesServices;
@@ -140,25 +152,29 @@ export async function servicesResponse() {
   const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
 
   mergedGroupsNames.forEach((groupName) => {
-    const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || {
+    const discoveredDockerGroup = findGroupByName(discoveredDockerServices, groupName) || {
       services: [],
     };
-    const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || {
+    const discoveredKubernetesGroup = findGroupByName(discoveredKubernetesServices, groupName) || {
       services: [],
     };
-    const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
+    const configuredGroup = findGroupByName(configuredServices, groupName) || { services: [] };
 
     const mergedGroup = {
       name: groupName,
       services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services]
         .filter((service) => service)
         .sort(compareServices),
+      groups: [...configuredGroup.groups],
     };
 
     if (definedLayouts) {
       const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name);
       if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup;
-      else unsortedGroups.push(mergedGroup);
+      else if (configuredGroup.name) {
+        // this is a nested group, so find the parent group and merge the services
+        mergeSubgroups(configuredServices, mergedGroup);
+      } else unsortedGroups.push(mergedGroup);
     } else {
       unsortedGroups.push(mergedGroup);
     }
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index e6ef6173..0f1e2c8c 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -13,6 +13,38 @@ import * as shvl from "utils/config/shvl";
 
 const logger = createLogger("service-helpers");
 
+function parseServicesToGroups(services) {
+  if (!services) {
+    return [];
+  }
+
+  // map easy to write YAML objects into easy to consume JS arrays
+  return services.map((serviceGroup) => {
+    const name = Object.keys(serviceGroup)[0];
+    let groups = [];
+    const serviceGroupServices = [];
+    serviceGroup[name].forEach((entries) => {
+      const entryName = Object.keys(entries)[0];
+      if (Array.isArray(entries[entryName])) {
+        groups = groups.concat(parseServicesToGroups([{ [entryName]: entries[entryName] }]));
+      } else {
+        serviceGroupServices.push({
+          name: entryName,
+          ...entries[entryName],
+          weight: entries[entryName].weight || serviceGroupServices.length * 100, // default weight
+          type: "service",
+        });
+      }
+    });
+    return {
+      name,
+      type: "group",
+      services: serviceGroupServices,
+      groups,
+    };
+  });
+}
+
 export async function servicesFromConfig() {
   checkAndCopyConfig("services.yaml");
 
@@ -20,31 +52,7 @@ export async function servicesFromConfig() {
   const rawFileContents = await fs.readFile(servicesYaml, "utf8");
   const fileContents = substituteEnvironmentVars(rawFileContents);
   const services = yaml.load(fileContents);
-
-  if (!services) {
-    return [];
-  }
-
-  // map easy to write YAML objects into easy to consume JS arrays
-  const servicesArray = services.map((servicesGroup) => ({
-    name: Object.keys(servicesGroup)[0],
-    services: servicesGroup[Object.keys(servicesGroup)[0]].map((entries) => ({
-      name: Object.keys(entries)[0],
-      ...entries[Object.keys(entries)[0]],
-      type: "service",
-    })),
-  }));
-
-  // add default weight to services based on their position in the configuration
-  servicesArray.forEach((group, groupIndex) => {
-    group.services.forEach((service, serviceIndex) => {
-      if (service.weight === undefined) {
-        servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100;
-      }
-    });
-  });
-
-  return servicesArray;
+  return parseServicesToGroups(services);
 }
 
 export async function servicesFromDocker() {
@@ -667,13 +675,30 @@ export function cleanServiceGroups(groups) {
       });
       return cleanedService;
     }),
+    type: serviceGroup.type || "group",
+    groups: serviceGroup.groups ? cleanServiceGroups(serviceGroup.groups) : [],
   }));
 }
 
+export function findGroupByName(groups, name) {
+  for (let i = 0; i < groups.length; i += 1) {
+    const group = groups[i];
+    if (group.name === name) {
+      return group;
+    } else if (group.groups) {
+      const foundGroup = findGroupByName(group.groups, name);
+      if (foundGroup) {
+        return foundGroup;
+      }
+    }
+  }
+  return null;
+}
+
 export async function getServiceItem(group, service) {
   const configuredServices = await servicesFromConfig();
 
-  const serviceGroup = configuredServices.find((g) => g.name === group);
+  const serviceGroup = findGroupByName(configuredServices, group);
   if (serviceGroup) {
     const serviceEntry = serviceGroup.services.find((s) => s.name === service);
     if (serviceEntry) return serviceEntry;
@@ -681,14 +706,14 @@ export async function getServiceItem(group, service) {
 
   const discoveredServices = await servicesFromDocker();
 
-  const dockerServiceGroup = discoveredServices.find((g) => g.name === group);
+  const dockerServiceGroup = findGroupByName(discoveredServices, group);
   if (dockerServiceGroup) {
     const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
     if (dockerServiceEntry) return dockerServiceEntry;
   }
 
   const kubernetesServices = await servicesFromKubernetes();
-  const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group);
+  const kubernetesServiceGroup = findGroupByName(kubernetesServices, group);
   if (kubernetesServiceGroup) {
     const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
     if (kubernetesServiceEntry) return kubernetesServiceEntry;