diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 8fce1eeb05..c2f9ef74b8 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -14,6 +14,7 @@ import { ConfigSummary, ConfigTypeRelationships } from "../types/configs"; +import { Property } from "../types/topology"; export * from "./configAccess"; @@ -164,6 +165,163 @@ export const getConfig = (id: string) => ConfigDB.get(`/config_detail?id=eq.${id}&select=*`) ); +export type ConfigItemPropertiesResponse = { + changed: boolean; + properties: Property[]; +}; + +export type ConfigPropertyRow = { + id?: string; + config_id: string; + scraper_id?: string | null; + created_by?: string | null; + name: string; + label?: string; + tooltip?: string; + icon?: string; + property_type?: string; + color?: string; + display_order?: number; + headline?: boolean; + hidden?: boolean; + text?: string; + value?: number | string; + unit?: string; + max?: number; + min?: number; + status?: string; + link_url?: string; + link_label?: string; + link_icon?: string; +}; + +const getConfigProperties = async (configId: string) => { + const result = await getConfig(configId); + return result.data?.[0]?.properties ?? []; +}; + +const toConfigPropertyRow = ( + configId: string, + createdBy: string, + property: Property +): ConfigPropertyRow => { + const firstLink = property.links?.[0]; + + return { + config_id: configId, + created_by: createdBy, + name: property.name, + label: property.label, + tooltip: property.tooltip, + icon: property.icon, + property_type: property.type, + color: property.color, + display_order: property.order, + headline: property.headline, + hidden: property.hidden, + text: property.text, + value: + property.value instanceof Date + ? property.value.getTime() + : property.value, + unit: property.unit, + max: property.max, + min: property.min, + status: property.status, + link_url: property.link ?? property.url ?? firstLink?.url, + link_label: property.link_label ?? firstLink?.label + }; +}; + +const toProperty = (row: ConfigPropertyRow): Property => ({ + name: row.name, + label: row.label, + tooltip: row.tooltip, + icon: row.icon, + type: row.property_type as Property["type"], + color: row.color, + order: row.display_order, + headline: row.headline, + hidden: row.hidden, + text: row.text, + value: row.value, + unit: row.unit, + max: row.max, + min: row.min, + status: row.status, + link: row.link_url, + link_label: row.link_label, + links: row.link_url + ? [ + { + label: row.link_label ?? row.link_url, + url: row.link_url + } + ] + : undefined +}); + +export const getManualConfigItemProperties = async ( + configId: string, + createdBy: string +) => { + const result = await resolvePostGrestRequestWithPagination< + ConfigPropertyRow[] + >( + ConfigDB.get( + `/config_properties?config_id=eq.${configId}&created_by=eq.${createdBy}&order=display_order.asc,name.asc` + ) + ); + + return result.data ?? []; +}; + +export const createConfigItemProperty = async ( + configId: string, + createdBy: string, + property: Property +): Promise => { + await ConfigDB.post( + "/config_properties", + toConfigPropertyRow(configId, createdBy, property), + { headers: { Prefer: "return=minimal" } } + ); + + return { changed: true, properties: await getConfigProperties(configId) }; +}; + +export const updateConfigItemProperty = async ( + propertyId: string, + configId: string, + createdBy: string, + property: Property +): Promise => { + const row: Partial = toConfigPropertyRow( + configId, + createdBy, + property + ); + delete row.config_id; + delete row.created_by; + + await ConfigDB.patch(`/config_properties?id=eq.${propertyId}`, row, { + headers: { Prefer: "return=minimal" } + }); + + return { changed: true, properties: await getConfigProperties(configId) }; +}; + +export const deleteConfigItemProperty = async ( + configId: string, + propertyId: string +) => { + await ConfigDB.delete(`/config_properties?id=eq.${propertyId}`); + + return { changed: true, properties: await getConfigProperties(configId) }; +}; + +export const configPropertyRowToProperty = toProperty; + export type ConfigsTagList = { key: string; value: any; diff --git a/src/api/types/configs.ts b/src/api/types/configs.ts index 61b1a57ef4..ea03c7975e 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -83,14 +83,7 @@ export interface ConfigItem extends Timestamped, Avatar, Agent, Costs { playbook_runs?: number; checks?: number; }; - properties?: { - icon: string; - name: string; - links: { - label: string; - url: string; - }[]; - }[]; + properties?: Property[] | null; last_scraped_time?: string; } diff --git a/src/api/types/topology.ts b/src/api/types/topology.ts index 4b8a6105ef..3cc6f091cf 100644 --- a/src/api/types/topology.ts +++ b/src/api/types/topology.ts @@ -8,6 +8,7 @@ export type Property = { name: string; icon?: string; label?: string; + tooltip?: string; type?: "url" | "badge" | "currency" | "text" | "age" | "hidden"; text?: string; max?: number; @@ -16,8 +17,13 @@ export type Property = { value?: ValueType; unit?: string; color?: string; + order?: number; + hidden?: boolean; + status?: string; namespace?: string; url?: string; + link?: string; + link_label?: string; links?: { label: string; url: string; diff --git a/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx b/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx new file mode 100644 index 0000000000..1930873e0d --- /dev/null +++ b/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx @@ -0,0 +1,151 @@ +import { createConfigItemProperty } from "@flanksource-ui/api/services/configs"; +import { Property } from "@flanksource-ui/api/types/topology"; +import FormikTextInput from "@flanksource-ui/components/Forms/Formik/FormikTextInput"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { Button } from "@flanksource-ui/ui/Buttons/Button"; +import { Modal } from "@flanksource-ui/ui/Modal"; +import { Field, Form, Formik } from "formik"; + +type Props = { + configId: string; + isOpen: boolean; + onClose: () => void; + onAdded?: (properties?: Property[]) => void; +}; + +type AddPropertyForm = { + name: string; + valueType: "text" | "value"; + text: string; + value: string; + link: string; + link_label: string; +}; + +export default function AddConfigPropertyModal({ + configId, + isOpen, + onClose, + onAdded +}: Props) { + const { user } = useUser(); + + return ( + + + initialValues={{ + name: "", + valueType: "text", + text: "", + value: "", + link: "", + link_label: "" + }} + onSubmit={async (values, formik) => { + if (!user?.id) { + toastError("Could not determine current user"); + return; + } + + if (!values.name) { + toastError("Please provide property name"); + formik.setSubmitting(false); + return; + } + + if (values.valueType === "text" && !values.text) { + toastError("Please provide property text"); + formik.setSubmitting(false); + return; + } + + if (values.valueType === "value" && values.value === "") { + toastError("Please provide property value"); + formik.setSubmitting(false); + return; + } + + try { + const newProperty: Property = { + name: values.name, + ...(values.valueType === "value" + ? { value: Number(values.value) } + : { text: values.text }), + ...(values.link + ? { link: values.link, link_label: values.link_label } + : {}) + }; + + const result = await createConfigItemProperty( + configId, + user.id, + newProperty + ); + toastSuccess("Property added"); + onAdded?.(result?.properties); + onClose(); + } catch (e) { + toastError((e as Error).message); + } finally { + formik.setSubmitting(false); + } + }} + > + {({ isSubmitting, values }) => ( +
+
+ + + {values.valueType === "value" ? ( + + ) : ( + + )} + + +
+
+ +
+
+ )} + +
+ ); +} diff --git a/src/components/Configs/Sidebar/ConfigDetails.tsx b/src/components/Configs/Sidebar/ConfigDetails.tsx index 7d97688244..5229e81e49 100644 --- a/src/components/Configs/Sidebar/ConfigDetails.tsx +++ b/src/components/Configs/Sidebar/ConfigDetails.tsx @@ -9,15 +9,19 @@ import TextSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/TextSkeletonLo import { refreshButtonClickedTrigger } from "@flanksource-ui/ui/SlidingSideBar/SlidingSideBar"; import dayjs from "dayjs"; import { useAtom } from "jotai"; -import { useMemo, useEffect } from "react"; -import { FaExclamationTriangle, FaTrash } from "react-icons/fa"; +import { useEffect, useMemo, useState } from "react"; +import { FaEdit, FaExclamationTriangle, FaPlus, FaTrash } from "react-icons/fa"; import { Link } from "react-router-dom"; +import { Tooltip } from "react-tooltip"; import { InfoMessage } from "../../InfoMessage"; import { Status } from "../../Status"; import DisplayDetailsRow from "../../Utils/DisplayDetailsRow"; import { DisplayGroupedProperties } from "../../Utils/DisplayGroupedProperties"; import ConfigCostValue from "../ConfigCosts/ConfigCostValue"; import ConfigsTypeIcon from "../ConfigsTypeIcon"; +import { IconButton } from "../../../ui/Buttons/IconButton"; +import AddConfigPropertyModal from "./AddConfigPropertyModal"; +import ManageConfigPropertiesModal from "./ManageConfigPropertiesModal"; import { formatConfigLabels } from "./Utils/formatConfigLabels"; import { MdOutlineSupportAgent } from "react-icons/md"; @@ -26,6 +30,9 @@ type Props = { }; export function ConfigDetails({ configId }: Props) { + const [isAddPropertyOpen, setIsAddPropertyOpen] = useState(false); + const [isManagePropertiesOpen, setIsManagePropertiesOpen] = useState(false); + const { data: configDetails, isLoading, @@ -252,8 +259,43 @@ export function ConfigDetails({ configId }: Props) { +
+ + Properties + +
+ } + onClick={() => setIsManagePropertiesOpen(true)} + /> + } + onClick={() => setIsAddPropertyOpen(true)} + /> +
+ + +
+ setIsAddPropertyOpen(false)} + onAdded={() => refetchConfig()} + /> + + setIsManagePropertiesOpen(false)} + onChanged={() => refetchConfig()} + /> + {!isCostsEmpty(configDetails) && ( void; + onChanged?: (properties?: Property[]) => void; +}; + +export default function ManageConfigPropertiesModal({ + configId, + isOpen, + onClose, + onChanged +}: Props) { + const { user } = useUser(); + const [userProperties, setUserProperties] = useState([]); + const [editingPropertyId, setEditingPropertyId] = useState(); + const [draftProperty, setDraftProperty] = useState({ + name: "", + text: "", + value: "", + link_url: "", + link_label: "" + }); + + useEffect(() => { + if (!isOpen || !user?.id) { + setUserProperties([]); + return; + } + + getManualConfigItemProperties(configId, user.id) + .then(setUserProperties) + .catch((e) => toastError((e as Error).message)); + }, [configId, isOpen, user?.id]); + + const startEditing = (property: ConfigPropertyRow) => { + setEditingPropertyId(property.id); + setDraftProperty({ + name: property.name, + text: property.text ?? "", + value: property.value?.toString() ?? "", + link_url: property.link_url ?? "", + link_label: property.link_label ?? "" + }); + }; + + const updateProperty = async (property: ConfigPropertyRow) => { + if (!property.id || !user?.id) { + toastError("Could not determine property id or current user"); + return; + } + + if (!draftProperty.name) { + toastError("Please provide property name"); + return; + } + + if (!draftProperty.text && draftProperty.value === "") { + toastError("Please provide property text or value"); + return; + } + + const nextProperty: ConfigPropertyRow = { + ...property, + name: draftProperty.name, + text: draftProperty.value === "" ? draftProperty.text : undefined, + value: + draftProperty.value === "" ? undefined : Number(draftProperty.value), + link_url: draftProperty.link_url || undefined, + link_label: draftProperty.link_label || undefined + }; + + try { + const result = await updateConfigItemProperty( + property.id, + configId, + user.id, + configPropertyRowToProperty(nextProperty) + ); + setUserProperties((properties) => + properties.map((item) => + item.id === property.id ? nextProperty : item + ) + ); + setEditingPropertyId(undefined); + toastSuccess("Property updated"); + onChanged?.(result?.properties); + } catch (e) { + toastError((e as Error).message); + } + }; + + const deleteProperty = async (property: ConfigPropertyRow) => { + if (!property.id) { + toastError("Could not determine property id"); + return; + } + + try { + const result = await deleteConfigItemProperty(configId, property.id); + setUserProperties((properties) => + properties.filter((item) => item.id !== property.id) + ); + toastSuccess("Property deleted"); + onChanged?.(result?.properties); + } catch (e) { + toastError((e as Error).message); + } + }; + + return ( + +
+ {userProperties.length === 0 ? ( +
+ You have not added any manual properties. +
+ ) : ( + userProperties.map((property) => { + const isEditing = editingPropertyId === property.id; + + return ( +
+
+ {isEditing ? ( +
+ + setDraftProperty((draft) => ({ + ...draft, + name: e.target.value + })) + } + placeholder="Name" + /> + + setDraftProperty((draft) => ({ + ...draft, + text: e.target.value, + value: "" + })) + } + placeholder="Text" + /> + + setDraftProperty((draft) => ({ + ...draft, + value: e.target.value, + text: "" + })) + } + placeholder="Value" + /> + + setDraftProperty((draft) => ({ + ...draft, + link_url: e.target.value + })) + } + placeholder="Link URL" + /> + + setDraftProperty((draft) => ({ + ...draft, + link_label: e.target.value + })) + } + placeholder="Link label" + /> +
+ ) : ( + <> +
+ {property.name} +
+
+ {String(property.text ?? property.value ?? "")} +
+ + )} +
+
+ {isEditing ? ( + <> + } + onClick={() => updateProperty(property)} + /> + } + onClick={() => setEditingPropertyId(undefined)} + /> + + ) : ( + } + onClick={() => startEditing(property)} + /> + )} + } + onClick={() => deleteProperty(property)} + /> +
+
+ ); + }) + )} +
+
+ +
+
+ ); +} diff --git a/src/components/Topology/Sidebar/Utils/formatProperties.tsx b/src/components/Topology/Sidebar/Utils/formatProperties.tsx index b77d7908d5..d129842605 100644 --- a/src/components/Topology/Sidebar/Utils/formatProperties.tsx +++ b/src/components/Topology/Sidebar/Utils/formatProperties.tsx @@ -28,7 +28,11 @@ type SingleProperty = { export type PropertyItem = GroupedProperties | SingleProperty; -export function formatProperties(topology?: Pick) { +export function formatProperties( + topology?: + | Pick + | { properties?: Topology["properties"] | null } +) { if (topology == null) { return []; }