diff --git a/src/components/Configs/ConfigList/Cells/ConfigListCostCell.tsx b/src/components/Configs/ConfigList/Cells/ConfigListCostCell.tsx index 464edc2d1..70b738b2f 100644 --- a/src/components/Configs/ConfigList/Cells/ConfigListCostCell.tsx +++ b/src/components/Configs/ConfigList/Cells/ConfigListCostCell.tsx @@ -1,4 +1,5 @@ import { CellContext, Row } from "@tanstack/react-table"; +import { MRT_Row } from "mantine-react-table"; import { ConfigItem, ConfigSummary, @@ -16,14 +17,19 @@ export default function ConfigListCostCell({ * Recursively aggregate costs for a given row and its children, and its children's children, etc. * */ -const aggregatedCosts = ( - rows: Row, +export const aggregatedCosts = ( + rows: Row | MRT_Row, data: Required ): Required => { - if (rows.subRows.length === 0) { + const subRows = rows.subRows; + if (!subRows) { return data; } - return rows.subRows.reduce((acc, row) => { + if (subRows.length === 0) { + return data; + } + // @ts-ignore + return subRows.reduce((acc, row) => { if (row.original) { acc.cost_total_30d! += row.original.cost_total_30d ?? 0; acc.cost_total_7d! += row.original.cost_total_7d ?? 0; diff --git a/src/components/Configs/ConfigList/Cells/ConfigListDateCell.tsx b/src/components/Configs/ConfigList/Cells/ConfigListDateCell.tsx index de1081d02..fe03823b7 100644 --- a/src/components/Configs/ConfigList/Cells/ConfigListDateCell.tsx +++ b/src/components/Configs/ConfigList/Cells/ConfigListDateCell.tsx @@ -1,3 +1,4 @@ +import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; import { CellContext } from "@tanstack/react-table"; import { FaTrash } from "react-icons/fa"; import { Age } from "../../../../ui/Age"; @@ -22,3 +23,24 @@ export default function ConfigListDateCell>({ ); } + +export function MRTConfigListDateCell>({ + column, + row, + cell +}: MRTCellProps) { + const dateString = cell.getValue(); + if (dateString === "0001-01-01T00:00:00") { + return null; + } + const isDeleted = !!row.original.deleted_at; + const value = isDeleted ? row.original.deleted_at : dateString; + return ( +
+ + {column.id === "updated_at" && isDeleted && ( + + )} +
+ ); +} diff --git a/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx b/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx new file mode 100644 index 000000000..ecde2a5a0 --- /dev/null +++ b/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx @@ -0,0 +1,117 @@ +import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; +import { Tag } from "@flanksource-ui/ui/Tags/Tag"; +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import { ConfigItem } from "../../../../api/types/configs"; + +type MRTConfigListTagsCellProps< + T extends { + tags?: Record; + id: string; + } +> = MRTCellProps & { + hideGroupByView?: boolean; + enableFilterByTag?: boolean; +}; + +export default function MRTConfigListTagsCell< + T extends { tags?: Record; id: string } +>({ + row, + cell, + hideGroupByView = false, + enableFilterByTag = false +}: MRTConfigListTagsCellProps): JSX.Element | null { + const [params, setParams] = useSearchParams(); + + const tagMap = cell.getValue() || {}; + const tagKeys = Object.keys(tagMap) + .sort() + .filter((key) => key !== "toString"); + + const onFilterByTag = useCallback( + ( + e: React.MouseEvent, + tag: { + key: string; + value: string; + }, + action: "include" | "exclude" + ) => { + if (!enableFilterByTag) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + // Get the current tags from the URL + const currentTags = params.get("tags"); + const currentTagsArray = ( + currentTags ? currentTags.split(",") : [] + ).filter((value) => { + const tagKey = value.split("____")[0]; + const tagAction = value.split(":")[1] === "1" ? "include" : "exclude"; + + if (tagKey === tag.key && tagAction !== action) { + return false; + } + return true; + }); + + // Append the new value, but for same tags, don't allow including and excluding at the same time + const updatedValue = currentTagsArray + .concat(`${tag.key}____${tag.value}:${action === "include" ? 1 : -1}`) + .filter((value, index, self) => self.indexOf(value) === index) + .join(","); + + // Update the URL + params.set("tags", updatedValue); + setParams(params); + }, + [enableFilterByTag, params, setParams] + ); + + const groupByProp = decodeURIComponent(params.get("groupByProp") ?? ""); + + if (tagKeys.length === 0) { + return null; + } + + if (!hideGroupByView && groupByProp) { + if (!tagMap[groupByProp]) { + return null; + } + + return ( +
+
+ {groupByProp}:{" "} + {tagMap[groupByProp]} +
+
+ ); + } + + return ( +
+ {Object.entries(tagMap).map(([key, value]) => ( + + {value} + + ))} +
+ ); +} diff --git a/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx b/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx index ff13fb437..fbc29ee01 100644 --- a/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx +++ b/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx @@ -1,9 +1,8 @@ import { ConfigItem } from "@flanksource-ui/api/types/configs"; -import { DataTable } from "@flanksource-ui/ui/DataTable"; -import { Row, SortingState, Updater } from "@tanstack/react-table"; -import { useCallback, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { configListColumns } from "./ConfigListColumn"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { mrtConfigListColumns } from "./MRTConfigListColumn"; export interface Props { data: ConfigItem[]; @@ -11,6 +10,8 @@ export interface Props { columnsToHide?: string[]; groupBy?: string[]; expandAllRows?: boolean; + totalEntries?: number; + pageCount?: number; } export default function ConfigsRelationshipsTable({ @@ -18,104 +19,17 @@ export default function ConfigsRelationshipsTable({ isLoading, columnsToHide = ["type"], groupBy = [], - expandAllRows = false + expandAllRows = false, + totalEntries, + pageCount }: Props) { - const [queryParams, setSearchParams] = useSearchParams({ - sortBy: "type", - sortOrder: "asc" - }); - const navigate = useNavigate(); - const sortField = queryParams.get("sortBy") ?? "type"; - - const isSortOrderDesc = - queryParams.get("sortOrder") === "desc" ? true : false; - - const determineSortColumnOrder = useCallback( - (sortState: SortingState): SortingState => { - const sortStateWithoutDeletedAt = sortState.filter( - (sort) => sort.id !== "deleted_at" - ); - return [{ id: "deleted_at", desc: false }, ...sortStateWithoutDeletedAt]; - }, - [] - ); - - const [sortBy, setSortBy] = useState(() => { - return sortField - ? determineSortColumnOrder([ - { - id: sortField, - desc: isSortOrderDesc - }, - ...(sortField !== "name" - ? [ - { - id: "name", - desc: isSortOrderDesc - } - ] - : []) - ]) - : determineSortColumnOrder([]); - }); - - const updateSortBy = useCallback( - (newSortBy: Updater) => { - const getSortBy = Array.isArray(newSortBy) - ? newSortBy - : newSortBy(sortBy); - // remove deleted_at from sort state, we don't want it to be save to the - // URL for the purpose of sorting - const sortStateWithoutDeleteAt = getSortBy.filter( - (state) => state.id !== "deleted_at" - ); - const { id: field, desc } = sortStateWithoutDeleteAt[0] ?? {}; - const order = desc ? "desc" : "asc"; - if (field && order && field !== "type" && order !== "asc") { - queryParams.set("sortBy", field); - queryParams.set("sortOrder", order); - } else { - queryParams.delete("sortBy"); - queryParams.delete("sortOrder"); - } - setSearchParams(queryParams); - const sortByValue = - typeof newSortBy === "function" ? newSortBy(sortBy) : newSortBy; - if (sortByValue.length > 0) { - setSortBy(determineSortColumnOrder(sortByValue)); - } - }, - [determineSortColumnOrder, queryParams, setSearchParams, sortBy] - ); - const hiddenColumns = ["deleted_at", "changed", ...columnsToHide]; - const determineRowClassNames = useCallback((row: Row) => { - if (row.getIsGrouped()) { - // check if the whole group is deleted - const allDeleted = row.getLeafRows().every((row) => { - if (row.original.deleted_at) { - return true; - } - return false; - }); - - if (allDeleted) { - return "text-gray-500"; - } - } else { - if (row.original.deleted_at) { - return "text-gray-500"; - } - } - return ""; - }, []); - const handleRowClick = useCallback( - (row?: { original?: { id: string } }) => { - const id = row?.original?.id; + (row?: ConfigItem) => { + const id = row?.id; if (id) { navigate(`/catalog/${id}`); } @@ -124,25 +38,16 @@ export default function ConfigsRelationshipsTable({ ); return ( - ); } diff --git a/src/components/Configs/ConfigList/MRTConfigListColumn.tsx b/src/components/Configs/ConfigList/MRTConfigListColumn.tsx new file mode 100644 index 000000000..fa9b6e175 --- /dev/null +++ b/src/components/Configs/ConfigList/MRTConfigListColumn.tsx @@ -0,0 +1,303 @@ +import { Status } from "@flanksource-ui/components/Status"; +import { Badge } from "@flanksource-ui/ui/Badge/Badge"; +import ChangeCountIcon, { + CountBar +} from "@flanksource-ui/ui/Icons/ChangeCount"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { FaTrash } from "react-icons/fa"; +import { IoChevronDown, IoChevronForward } from "react-icons/io5"; +import { ConfigItem, ConfigSummary, Costs } from "../../../api/types/configs"; +import { TIME_BUCKETS, getTimeBucket } from "../../../utils/date"; +import ConfigCostValue from "../ConfigCosts/ConfigCostValue"; +import ConfigsTypeIcon from "../ConfigsTypeIcon"; +import ConfigInsightsIcon from "../Insights/ConfigInsightsIcon"; +import { aggregatedCosts } from "./Cells/ConfigListCostCell"; +import { MRTConfigListDateCell } from "./Cells/ConfigListDateCell"; +import MRTConfigListTagsCell from "./Cells/MRTConfigListTagsCell"; + +export const mrtConfigListColumns: MRT_ColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + Cell: ({ row, cell }) => { + const configType = row.original.type; + + return ( +
+ + {cell.getValue()} + +
+ ); + }, + minSize: 200, + size: 270, + enableGrouping: true, + enableSorting: true, + enableHiding: false, + AggregatedCell: ({ row }) => { + if (row.getCanExpand()) { + const groupingValue = row.getGroupingValue( + row.groupingColumnId! + ) as string; + const count = row.subRows?.length; + return ( +
+ {row.getIsExpanded() ? : } + {row.groupingColumnId === "type" ? ( + + + + ) : ( + <> + {groupingValue && ( + {groupingValue} + )} + + + )} +
+ ); + } + } + }, + { + header: "Type", + accessorKey: "type", + size: 170, + enableSorting: true, + enableHiding: true, + Cell: ({ row }) => { + return ; + }, + AggregatedCell: ({ row }) => { + console.log(row); + + return null; + } + }, + { + header: "Status", + accessorKey: "health", + minSize: 100, + maxSize: 180, + enableSorting: true, + Cell: ({ cell, row }) => { + const health = cell.getValue(); + const status = row.original.status; + + if (row.original.deleted_at) { + return ( + + + {status} + + ); + } + + return ; + } + }, + { + header: "Changes", + accessorKey: "changes", + id: "changes", + Cell: ({ row, column }) => { + const changes = row?.getValue(column.id); + + if (!changes) { + return null; + } + + return ; + }, + enableGrouping: true, + AggregatedCell: ({ cell }) => { + const value = cell.getValue(); + + if (!value) { + return null; + } + + return ; + }, + size: 70, + meta: { + cellClassName: "overflow-hidden" + }, + enableSorting: false + }, + { + header: "Tags", + accessorKey: "tags", + Cell: (props) => , + maxSize: 300, + minSize: 100 + }, + { + header: "Analysis", + accessorKey: "analysis", + Cell: ({ row, cell }) => { + const value = cell.getValue(); + if (!value) { + return null; + } + + return ( +
+ { + return { + count: value, + icon: ( + + ) + }; + })} + /> +
+ ); + }, + AggregatedCell: ({ cell }) => { + const value = cell.getValue(); + if (!value) { + return null; + } + + return ( +
+ { + return { + count: value, + icon: ( + + ) + }; + })} + /> +
+ ); + }, + minSize: 50, + maxSize: 100 + }, + { + header: "Cost", + accessorKey: "cost_total_1d", + aggregationFn: "sum", + AggregatedCell: ({ row }) => { + const configGroupCosts = aggregatedCosts(row, { + cost_total_30d: 0, + cost_total_7d: 0, + cost_total_1d: 0, + cost_per_minute: 0 + } as Required); + return ; + }, + Cell: ({ row }) => { + return ; + }, + maxSize: 60 + }, + // { + // header: "Agent", + // accessorKey: "agent", + // enableSorting: false, + // cell: ({ getValue }: CellContext) => { + // const agent = getValue(); + // if (agent?.name === "local") { + // return null; + // } + // return {agent?.name}; + // } + // }, + + // { + // header: "Tags", + // accessorKey: "tags", + // cell: React.memo((props) => ( + // + // )), + // size: 240 + // }, + // { + // header: "Labels", + // accessorKey: "labels", + // cell: React.memo((props) => ( + // + // )), + // size: 240 + // }, + { + header: "Created", + accessorKey: "created_at", + Cell: MRTConfigListDateCell, + maxSize: 70 + }, + { + header: "Updated", + accessorKey: "updated_at", + Cell: MRTConfigListDateCell, + maxSize: 70 + }, + { + header: "Deleted At", + accessorKey: "deleted_at", + Cell: MRTConfigListDateCell, + size: 90, + enableHiding: true + }, + { + header: "Changed", + accessorFn: changeColumnAccessorFN, + id: "changed", + sortingFn: changeColumnSortingFN, + size: 180 + } +]; + +function changeColumnAccessorFN(row: any) { + return getTimeBucket(row.updated_at); +} + +function changeColumnSortingFN(rowA: any, rowB: any, columnId: string) { + const rowAOrder = + Object.values(TIME_BUCKETS).find((tb) => tb.name === rowA.values[columnId]) + ?.sortOrder || 0; + const rowBOrder = + Object.values(TIME_BUCKETS).find((tb) => tb.name === rowB.values[columnId]) + ?.sortOrder || 0; + if (rowAOrder >= rowBOrder) { + return 1; + } else { + return -1; + } +} diff --git a/src/ui/MRTDataTable/MRTDataTable.tsx b/src/ui/MRTDataTable/MRTDataTable.tsx index bc1065b4b..73db9fa2f 100644 --- a/src/ui/MRTDataTable/MRTDataTable.tsx +++ b/src/ui/MRTDataTable/MRTDataTable.tsx @@ -23,6 +23,8 @@ type MRTDataTableProps = {}> = { * pagination to determine the total number of pages. */ totalRowCount?: number; + groupBy?: string[]; + expandAllRows?: boolean; }; export default function MRTDataTable = {}>({ @@ -35,7 +37,9 @@ export default function MRTDataTable = {}>({ enableServerSidePagination = false, manualPageCount, totalRowCount, - hiddenColumns = [] + hiddenColumns = [], + groupBy = [], + expandAllRows = false }: MRTDataTableProps) { const { pageIndex, pageSize, setPageIndex } = useReactTablePaginationState(); const [sortState, setSortState] = useReactTableSortState(); @@ -94,7 +98,9 @@ export default function MRTDataTable = {}>({ pageIndex, pageSize }, - sorting: sortState + sorting: sortState, + grouping: groupBy, + expanded: expandAllRows ? true : undefined }, initialState: { columnVisibility: hiddenColumns.reduce((acc: VisibilityState, column) => {