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 };