Feature: nested groups (#4346)

This commit is contained in:
shamoon 2024-11-27 17:01:47 -08:00 committed by GitHub
parent 907abee1aa
commit be8363cc35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 119 additions and 55 deletions

View File

@ -3,12 +3,13 @@ import classNames from "classnames";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { MdKeyboardArrowDown } from "react-icons/md"; import { MdKeyboardArrowDown } from "react-icons/md";
import { columnMap } from "../../utils/layout/columns";
import List from "components/services/list"; import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon"; import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ export default function ServicesGroup({
group, group,
services,
layout, layout,
fiveColumns, fiveColumns,
disableCollapse, disableCollapse,
@ -23,7 +24,7 @@ export default function ServicesGroup({
return ( return (
<div <div
key={services.name} key={group.name}
className={classNames( className={classNames(
"services-group", "services-group",
layout?.style === "row" ? "basis-full" : "basis-full md:basis-1/2 lg:basis-1/3 xl:basis-1/4", 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> </div>
)} )}
<h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name"> <h2 className="flex text-theme-800 dark:text-theme-300 text-xl font-medium service-group-name">
{services.name} {group.name}
</h2> </h2>
<MdKeyboardArrowDown <MdKeyboardArrowDown
className={classNames( className={classNames(
@ -74,7 +75,31 @@ export default function ServicesGroup({
}} }}
> >
<Disclosure.Panel className="transition-all overflow-hidden duration-300 ease-out" ref={panel} static> <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> </Disclosure.Panel>
</Transition> </Transition>
</> </>

View File

@ -12,7 +12,7 @@ import Kubernetes from "widgets/kubernetes/component";
import { SettingsContext } from "utils/contexts/settings"; import { SettingsContext } from "utils/contexts/settings";
import ResolvedIcon from "components/resolvedicon"; 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 hasLink = service.href && service.href !== "#";
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const showStats = service.showStats === false ? false : settings.showStats; const showStats = service.showStats === false ? false : settings.showStats;
@ -90,14 +90,14 @@ export default function Item({ service, group, useEqualHeights }) {
> >
{service.ping && ( {service.ping && (
<div className="flex-shrink-0 flex items-center justify-center service-tag 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> <span className="sr-only">Ping status</span>
</div> </div>
)} )}
{service.siteMonitor && ( {service.siteMonitor && (
<div className="flex-shrink-0 flex items-center justify-center service-tag service-site-monitor"> <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> <span className="sr-only">Site monitor status</span>
</div> </div>
)} )}

View File

@ -4,7 +4,7 @@ import { columnMap } from "../../utils/layout/columns";
import Item from "components/services/item"; import Item from "components/services/item";
export default function List({ group, services, layout, useEqualHeights }) { export default function List({ groupName, services, layout, useEqualHeights }) {
return ( return (
<ul <ul
className={classNames( className={classNames(
@ -16,7 +16,7 @@ export default function List({ group, services, layout, useEqualHeights }) {
<Item <Item
key={[service.container, service.app, service.name].filter((s) => s).join("-")} key={[service.container, service.app, service.name].filter((s) => s).join("-")}
service={service} service={service}
group={group} groupName={groupName}
useEqualHeights={layout?.useEqualHeights ?? useEqualHeights} useEqualHeights={layout?.useEqualHeights ?? useEqualHeights}
/> />
))} ))}

View File

@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
export default function Ping({ group, service, style }) { export default function Ping({ groupName, serviceName, style }) {
const { t } = useTranslation(); 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, refreshInterval: 30000,
}); });

View File

@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
export default function SiteMonitor({ group, service, style }) { export default function SiteMonitor({ groupName, serviceName, style }) {
const { t } = useTranslation(); 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, refreshInterval: 30000,
}); });

View File

@ -6,10 +6,10 @@ import createLogger from "utils/logger";
const logger = createLogger("ping"); const logger = createLogger("ping");
export default async function handler(req, res) { export default async function handler(req, res) {
const { group, service } = req.query; const { groupName, serviceName } = req.query;
const serviceItem = await getServiceItem(group, service); const serviceItem = await getServiceItem(groupName, serviceName);
if (!serviceItem) { 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({ return res.status(400).send({
error: "Unable to find service, see log for details.", error: "Unable to find service, see log for details.",
}); });

View File

@ -7,10 +7,10 @@ import { httpProxy } from "utils/proxy/http";
const logger = createLogger("siteMonitor"); const logger = createLogger("siteMonitor");
export default async function handler(req, res) { export default async function handler(req, res) {
const { group, service } = req.query; const { groupName, serviceName } = req.query;
const serviceItem = await getServiceItem(group, service); const serviceItem = await getServiceItem(groupName, serviceName);
if (!serviceItem) { 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({ return res.status(400).send({
error: "Unable to find service, see log for details.", error: "Unable to find service, see log for details.",
}); });

View File

@ -291,8 +291,7 @@ function Home({ initialSettings }) {
group.services ? ( group.services ? (
<ServicesGroup <ServicesGroup
key={group.name} key={group.name}
group={group.name} group={group}
services={group}
layout={settings.layout?.[group.name]} layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns} fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}
@ -316,8 +315,7 @@ function Home({ initialSettings }) {
{serviceGroups.map((group) => ( {serviceGroups.map((group) => (
<ServicesGroup <ServicesGroup
key={group.name} key={group.name}
group={group.name} group={group}
services={group}
layout={settings.layout?.[group.name]} layout={settings.layout?.[group.name]}
fiveColumns={settings.fiveColumns} fiveColumns={settings.fiveColumns}
disableCollapse={settings.disableCollapse} disableCollapse={settings.disableCollapse}

View File

@ -10,6 +10,7 @@ import {
servicesFromDocker, servicesFromDocker,
cleanServiceGroups, cleanServiceGroups,
servicesFromKubernetes, servicesFromKubernetes,
findGroupByName,
} from "utils/config/service-helpers"; } from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers"; import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
@ -84,6 +85,17 @@ export async function widgetsResponse() {
return configuredWidgets; 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() { export async function servicesResponse() {
let discoveredDockerServices; let discoveredDockerServices;
let discoveredKubernetesServices; let discoveredKubernetesServices;
@ -140,25 +152,29 @@ export async function servicesResponse() {
const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null; const definedLayouts = initialSettings.layout ? Object.keys(initialSettings.layout) : null;
mergedGroupsNames.forEach((groupName) => { mergedGroupsNames.forEach((groupName) => {
const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || { const discoveredDockerGroup = findGroupByName(discoveredDockerServices, groupName) || {
services: [], services: [],
}; };
const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || { const discoveredKubernetesGroup = findGroupByName(discoveredKubernetesServices, groupName) || {
services: [], services: [],
}; };
const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] }; const configuredGroup = findGroupByName(configuredServices, groupName) || { services: [] };
const mergedGroup = { const mergedGroup = {
name: groupName, name: groupName,
services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services] services: [...discoveredDockerGroup.services, ...discoveredKubernetesGroup.services, ...configuredGroup.services]
.filter((service) => service) .filter((service) => service)
.sort(compareServices), .sort(compareServices),
groups: [...configuredGroup.groups],
}; };
if (definedLayouts) { if (definedLayouts) {
const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name); const layoutIndex = definedLayouts.findIndex((layout) => layout === mergedGroup.name);
if (layoutIndex > -1) sortedGroups[layoutIndex] = mergedGroup; 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 { } else {
unsortedGroups.push(mergedGroup); unsortedGroups.push(mergedGroup);
} }

View File

@ -13,6 +13,38 @@ import * as shvl from "utils/config/shvl";
const logger = createLogger("service-helpers"); 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() { export async function servicesFromConfig() {
checkAndCopyConfig("services.yaml"); checkAndCopyConfig("services.yaml");
@ -20,31 +52,7 @@ export async function servicesFromConfig() {
const rawFileContents = await fs.readFile(servicesYaml, "utf8"); const rawFileContents = await fs.readFile(servicesYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents); const fileContents = substituteEnvironmentVars(rawFileContents);
const services = yaml.load(fileContents); const services = yaml.load(fileContents);
return parseServicesToGroups(services);
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;
} }
export async function servicesFromDocker() { export async function servicesFromDocker() {
@ -667,13 +675,30 @@ export function cleanServiceGroups(groups) {
}); });
return cleanedService; 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) { export async function getServiceItem(group, service) {
const configuredServices = await servicesFromConfig(); const configuredServices = await servicesFromConfig();
const serviceGroup = configuredServices.find((g) => g.name === group); const serviceGroup = findGroupByName(configuredServices, group);
if (serviceGroup) { if (serviceGroup) {
const serviceEntry = serviceGroup.services.find((s) => s.name === service); const serviceEntry = serviceGroup.services.find((s) => s.name === service);
if (serviceEntry) return serviceEntry; if (serviceEntry) return serviceEntry;
@ -681,14 +706,14 @@ export async function getServiceItem(group, service) {
const discoveredServices = await servicesFromDocker(); const discoveredServices = await servicesFromDocker();
const dockerServiceGroup = discoveredServices.find((g) => g.name === group); const dockerServiceGroup = findGroupByName(discoveredServices, group);
if (dockerServiceGroup) { if (dockerServiceGroup) {
const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service); const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
if (dockerServiceEntry) return dockerServiceEntry; if (dockerServiceEntry) return dockerServiceEntry;
} }
const kubernetesServices = await servicesFromKubernetes(); const kubernetesServices = await servicesFromKubernetes();
const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group); const kubernetesServiceGroup = findGroupByName(kubernetesServices, group);
if (kubernetesServiceGroup) { if (kubernetesServiceGroup) {
const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service); const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
if (kubernetesServiceEntry) return kubernetesServiceEntry; if (kubernetesServiceEntry) return kubernetesServiceEntry;