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;