github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/ui/dashboard/src/components/DashboardList/index.tsx (about)

     1  import get from "lodash/get";
     2  import sortBy from "lodash/sortBy";
     3  import CallToActions from "../CallToActions";
     4  import LoadingIndicator from "../dashboards/LoadingIndicator";
     5  import {
     6    AvailableDashboard,
     7    AvailableDashboardsDictionary,
     8    DashboardAction,
     9    DashboardActions,
    10    ModDashboardMetadata,
    11  } from "../../types";
    12  import { classNames } from "../../utils/styles";
    13  import { default as lodashGroupBy } from "lodash/groupBy";
    14  import { Fragment, useEffect, useState } from "react";
    15  import { getComponent } from "../dashboards";
    16  import { stringToColor } from "../../utils/color";
    17  import { useDashboard } from "../../hooks/useDashboard";
    18  import { useParams } from "react-router-dom";
    19  
    20  type DashboardListSection = {
    21    title: string;
    22    dashboards: AvailableDashboardWithMod[];
    23  };
    24  
    25  type AvailableDashboardWithMod = AvailableDashboard & {
    26    mod?: ModDashboardMetadata;
    27  };
    28  
    29  type DashboardTagProps = {
    30    tagKey: string;
    31    tagValue: string;
    32    dispatch?: (action: DashboardAction) => void;
    33    searchValue?: string;
    34  };
    35  
    36  type SectionProps = {
    37    title: string;
    38    dashboards: AvailableDashboardWithMod[];
    39    dispatch: (action: DashboardAction) => void;
    40    searchValue: string;
    41  };
    42  
    43  const DashboardTag = ({
    44    tagKey,
    45    tagValue,
    46    dispatch,
    47    searchValue,
    48  }: DashboardTagProps) => (
    49    <span
    50      className={classNames(
    51        "rounded-md text-xs",
    52        dispatch ? "cursor-pointer" : null
    53      )}
    54      onClick={
    55        dispatch
    56          ? () => {
    57              const existingSearch = searchValue ? searchValue.trim() : "";
    58              const searchWithTag = existingSearch
    59                ? existingSearch.indexOf(tagValue) < 0
    60                  ? `${existingSearch} ${tagValue}`
    61                  : existingSearch
    62                : tagValue;
    63              dispatch({
    64                type: DashboardActions.SET_DASHBOARD_SEARCH_VALUE,
    65                value: searchWithTag,
    66              });
    67            }
    68          : undefined
    69      }
    70      style={{ color: stringToColor(tagValue) }}
    71      title={`${tagKey} = ${tagValue}`}
    72    >
    73      {tagValue}
    74    </span>
    75  );
    76  
    77  const TitlePart = ({ part }) => {
    78    const ExternalLink = getComponent("external_link");
    79    return (
    80      <ExternalLink
    81        className="link-highlight hover:underline"
    82        ignoreDataMode
    83        to={`/${part.full_name}`}
    84      >
    85        {part.title || part.short_name}
    86      </ExternalLink>
    87    );
    88  };
    89  
    90  const BenchmarkTitle = ({ benchmark, searchValue }) => {
    91    const { dashboardsMap } = useDashboard();
    92    const ExternalLink = getComponent("external_link");
    93  
    94    if (!searchValue) {
    95      return (
    96        <ExternalLink
    97          className="link-highlight hover:underline"
    98          ignoreDataMode
    99          to={`/${benchmark.full_name}`}
   100        >
   101          {benchmark.title || benchmark.short_name}
   102        </ExternalLink>
   103      );
   104    }
   105  
   106    const parts: AvailableDashboard[] = [];
   107  
   108    for (const trunk of benchmark.trunks[0]) {
   109      const part = dashboardsMap[trunk];
   110      if (part) {
   111        parts.push(part);
   112      }
   113    }
   114  
   115    return (
   116      <>
   117        {parts.map((part, index) => (
   118          <Fragment key={part.full_name}>
   119            {!!index && (
   120              <span className="px-1 text-sm text-foreground-lighter">{">"}</span>
   121            )}
   122            <TitlePart part={part} />
   123          </Fragment>
   124        ))}
   125      </>
   126    );
   127  };
   128  
   129  const Section = ({
   130    title,
   131    dashboards,
   132    dispatch,
   133    searchValue,
   134  }: SectionProps) => {
   135    return (
   136      <div className="space-y-2">
   137        <h3 className="truncate">{title}</h3>
   138        {dashboards.map((dashboard) => (
   139          <div key={dashboard.full_name} className="flex space-x-2 items-center">
   140            <div className="md:col-span-6 truncate">
   141              {(dashboard.type === "dashboard" ||
   142                dashboard.type === "snapshot") && <TitlePart part={dashboard} />}
   143              {dashboard.type === "benchmark" && (
   144                <BenchmarkTitle benchmark={dashboard} searchValue={searchValue} />
   145              )}
   146            </div>
   147            <div className="hidden md:block col-span-6 space-x-2">
   148              {Object.entries(dashboard.tags || {}).map(([key, value]) => {
   149                if (key !== "category" && key !== "service" && key !== "type") {
   150                  return null;
   151                }
   152                return (
   153                  <DashboardTag
   154                    key={key}
   155                    tagKey={key}
   156                    tagValue={value}
   157                    dispatch={dispatch}
   158                    searchValue={searchValue}
   159                  />
   160                );
   161              })}
   162            </div>
   163          </div>
   164        ))}
   165      </div>
   166    );
   167  };
   168  
   169  type GroupedDashboards = {
   170    [key: string]: AvailableDashboardWithMod[];
   171  };
   172  
   173  const useGroupedDashboards = (dashboards, group_by, metadata) => {
   174    const [sections, setSections] = useState<DashboardListSection[]>([]);
   175  
   176    useEffect(() => {
   177      let groupedDashboards: GroupedDashboards;
   178      if (group_by.value === "tag") {
   179        groupedDashboards = lodashGroupBy(dashboards, (dashboard) => {
   180          return get(dashboard, `tags["${group_by.tag}"]`, "Other");
   181        });
   182      } else {
   183        groupedDashboards = lodashGroupBy(dashboards, (dashboard) => {
   184          return get(
   185            dashboard,
   186            `mod.title`,
   187            get(dashboard, "mod.short_name", "Other")
   188          );
   189        });
   190      }
   191      setSections(
   192        Object.entries(groupedDashboards)
   193          .map(([k, v]) => ({
   194            title: k,
   195            dashboards: v,
   196          }))
   197          .sort((x, y) => {
   198            if (y.title === "Other") {
   199              return -1;
   200            }
   201            if (x.title < y.title) {
   202              return -1;
   203            }
   204            if (x.title > y.title) {
   205              return 1;
   206            }
   207            return 0;
   208          })
   209      );
   210    }, [dashboards, group_by, metadata]);
   211  
   212    return sections;
   213  };
   214  
   215  const searchAgainstDashboard = (
   216    dashboard: AvailableDashboardWithMod,
   217    searchParts: string[]
   218  ): boolean => {
   219    const joined = `${dashboard.mod?.title || dashboard.mod?.short_name || ""} ${
   220      dashboard.title || dashboard.short_name || ""
   221    } ${Object.entries(dashboard.tags || {})
   222      .map(([tagKey, tagValue]) => `${tagKey}=${tagValue}`)
   223      .join(" ")}`.toLowerCase();
   224    return searchParts.every((searchPart) => joined.indexOf(searchPart) >= 0);
   225  };
   226  
   227  const sortDashboardSearchResults = (
   228    dashboards: AvailableDashboard[] = [],
   229    dashboardsMap: AvailableDashboardsDictionary
   230  ) => {
   231    return sortBy(dashboards, [
   232      (d) => {
   233        if (
   234          d.type === "dashboard" ||
   235          !d.trunks ||
   236          d.trunks.length === 0 ||
   237          d.trunks[0].length === 0
   238        ) {
   239          return (d.title || d.short_name).toLowerCase();
   240        }
   241        return d.trunks[0]
   242          .map((t) => {
   243            const part = dashboardsMap[t];
   244            if (!part) {
   245              return null;
   246            }
   247            return part.title || part.short_name;
   248          })
   249          .filter((t) => !!t)
   250          .join(" > ")
   251          .toLowerCase();
   252      },
   253    ]);
   254  };
   255  
   256  const DashboardList = () => {
   257    const {
   258      availableDashboardsLoaded,
   259      components: { DashboardListEmptyCallToAction },
   260      dashboards,
   261      dashboardsMap,
   262      dispatch,
   263      metadata,
   264      search: { value: searchValue, groupBy: searchGroupBy },
   265    } = useDashboard();
   266    const [unfilteredDashboards, setUnfilteredDashboards] = useState<
   267      AvailableDashboardWithMod[]
   268    >([]);
   269    const [unfilteredTopLevelDashboards, setUnfilteredTopLevelDashboards] =
   270      useState<AvailableDashboardWithMod[]>([]);
   271    const [filteredDashboards, setFilteredDashboards] = useState<
   272      AvailableDashboardWithMod[]
   273    >([]);
   274  
   275    // Initialise dashboards with their mod + update when the list of dashboards is updated
   276    useEffect(() => {
   277      if (!metadata || !availableDashboardsLoaded || !dashboardsMap) {
   278        setUnfilteredDashboards([]);
   279        return;
   280      }
   281  
   282      const dashboardsWithMod: AvailableDashboardWithMod[] = [];
   283      const topLevelDashboardsWithMod: AvailableDashboardWithMod[] = [];
   284      const newDashboardTagKeys: string[] = [];
   285      for (const dashboard of dashboards) {
   286        const dashboardMod = dashboard.mod_full_name;
   287        let mod: ModDashboardMetadata;
   288        if (dashboardMod === metadata.mod.full_name) {
   289          mod = get(metadata, "mod", {}) as ModDashboardMetadata;
   290        } else {
   291          mod = get(
   292            metadata,
   293            `installed_mods["${dashboardMod}"]`,
   294            {}
   295          ) as ModDashboardMetadata;
   296        }
   297        let dashboardWithMod: AvailableDashboardWithMod;
   298        dashboardWithMod = { ...dashboard };
   299        dashboardWithMod.mod = mod;
   300        dashboardsWithMod.push(dashboardWithMod);
   301  
   302        if (dashboard.is_top_level) {
   303          topLevelDashboardsWithMod.push(dashboardWithMod);
   304        }
   305  
   306        Object.entries(dashboard.tags || {}).forEach(([tagKey]) => {
   307          if (!newDashboardTagKeys.includes(tagKey)) {
   308            newDashboardTagKeys.push(tagKey);
   309          }
   310        });
   311      }
   312      setUnfilteredDashboards(dashboardsWithMod);
   313      setUnfilteredTopLevelDashboards(topLevelDashboardsWithMod);
   314      dispatch({
   315        type: DashboardActions.SET_DASHBOARD_TAG_KEYS,
   316        keys: newDashboardTagKeys,
   317      });
   318    }, [
   319      availableDashboardsLoaded,
   320      dashboards,
   321      dispatch,
   322      dashboardsMap,
   323      metadata,
   324    ]);
   325  
   326    // Filter dashboards according to the search
   327    useEffect(() => {
   328      if (!availableDashboardsLoaded || !metadata) {
   329        return;
   330      }
   331      if (!searchValue) {
   332        setFilteredDashboards(unfilteredDashboards);
   333        return;
   334      }
   335  
   336      const searchParts = searchValue.trim().toLowerCase().split(" ");
   337      const filtered: AvailableDashboard[] = [];
   338  
   339      unfilteredDashboards.forEach((dashboard) => {
   340        const include = searchAgainstDashboard(dashboard, searchParts);
   341        if (include) {
   342          filtered.push(dashboard);
   343        }
   344      });
   345  
   346      setFilteredDashboards(sortDashboardSearchResults(filtered, dashboardsMap));
   347    }, [
   348      availableDashboardsLoaded,
   349      dashboardsMap,
   350      unfilteredDashboards,
   351      metadata,
   352      searchValue,
   353    ]);
   354  
   355    const sections = useGroupedDashboards(
   356      searchValue ? filteredDashboards : unfilteredTopLevelDashboards,
   357      searchGroupBy,
   358      metadata
   359    );
   360  
   361    return (
   362      <div className="w-full grid grid-cols-12 gap-x-4">
   363        <div className="col-span-12 lg:col-span-9 space-y-4">
   364          <div className="grid grid-cols-6">
   365            {(!availableDashboardsLoaded || !metadata) && (
   366              <div className="col-span-6 mt-2 ml-1 text-black-scale-4 flex items-center">
   367                <LoadingIndicator className="mr-3 w-5 h-5" />{" "}
   368                <span className="italic -ml-1">Loading...</span>
   369              </div>
   370            )}
   371            <div className="col-span-6">
   372              {availableDashboardsLoaded &&
   373                metadata &&
   374                filteredDashboards.length === 0 && (
   375                  <div className="col-span-6 mt-2">
   376                    {searchValue ? (
   377                      <>
   378                        <span>No search results.</span>{" "}
   379                        <span
   380                          className="link-highlight"
   381                          onClick={() =>
   382                            dispatch({
   383                              type: DashboardActions.SET_DASHBOARD_SEARCH_VALUE,
   384                              value: "",
   385                            })
   386                          }
   387                        >
   388                          Clear
   389                        </span>
   390                        .
   391                      </>
   392                    ) : (
   393                      <DashboardListEmptyCallToAction />
   394                    )}
   395                  </div>
   396                )}
   397              <div className="space-y-4">
   398                {sections.map((section) => (
   399                  <Section
   400                    key={section.title}
   401                    title={section.title}
   402                    dashboards={section.dashboards}
   403                    dispatch={dispatch}
   404                    searchValue={searchValue}
   405                  />
   406                ))}
   407              </div>
   408            </div>
   409          </div>
   410        </div>
   411        <div className="col-span-12 lg:col-span-3 mt-4 lg:mt-2">
   412          <CallToActions />
   413        </div>
   414      </div>
   415    );
   416  };
   417  
   418  const DashboardListWrapper = ({ wrapperClassName = "" }) => {
   419    const { dashboard_name } = useParams();
   420    const { search } = useDashboard();
   421  
   422    // If we have a dashboard selected and no search, we don't want to show the list
   423    if (dashboard_name && !search.value) {
   424      return null;
   425    }
   426  
   427    return (
   428      <div className={wrapperClassName}>
   429        <DashboardList />
   430      </div>
   431    );
   432  };
   433  
   434  export default DashboardListWrapper;
   435  
   436  export { DashboardTag };