From 14c83a5bf9f136935d5a580c165c0d5372bc7169 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 28 Apr 2026 22:19:58 +0545 Subject: [PATCH 1/3] feat(config): manage manual properties Add config sidebar controls for users to add manual properties with either text or numeric values and delete only their own manually-added properties.\n\nWire the UI to the config item property update/delete RPCs and carry property ownership fields through config property types. --- src/api/services/configs.ts | 42 +++++ src/api/types/configs.ts | 9 +- src/api/types/topology.ts | 2 + .../Sidebar/AddConfigPropertyModal.tsx | 151 ++++++++++++++++++ .../Configs/Sidebar/ConfigDetails.tsx | 48 +++++- .../Sidebar/ManageConfigPropertiesModal.tsx | 103 ++++++++++++ .../Sidebar/Utils/formatProperties.tsx | 6 +- 7 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 src/components/Configs/Sidebar/AddConfigPropertyModal.tsx create mode 100644 src/components/Configs/Sidebar/ManageConfigPropertiesModal.tsx diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 8fce1eeb05..513cfcdd4d 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,47 @@ export const getConfig = (id: string) => ConfigDB.get(`/config_detail?id=eq.${id}&select=*`) ); +export type UpdateConfigItemPropertiesResponse = { + changed: boolean; + properties: Property[]; +}; + +export const updateConfigItemProperties = async ( + configId: string, + creatorType: "person" | "scraper", + createdBy: string, + properties: Property[] +) => { + const res = await ConfigDB.post( + "/rpc/update_config_item_properties", + { + p_config_id: configId, + p_creator_type: creatorType, + p_created_by: createdBy, + p_properties: properties + } + ); + return res.data?.[0]; +}; + +export const deleteConfigItemProperty = async ( + configId: string, + creatorType: "person" | "scraper", + createdBy: string, + propertyName: string +) => { + const res = await ConfigDB.post( + "/rpc/delete_config_item_property", + { + p_config_id: configId, + p_creator_type: creatorType, + p_created_by: createdBy, + p_property_name: propertyName + } + ); + return res.data?.[0]; +}; + 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..b62433b0e4 100644 --- a/src/api/types/topology.ts +++ b/src/api/types/topology.ts @@ -22,6 +22,8 @@ export type Property = { label: string; url: string; }[]; + created_by?: string; + creator_type?: "scraper" | "person" | string; }; export interface Component extends Timestamped, Namespaced { diff --git a/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx b/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx new file mode 100644 index 0000000000..89aef4ef62 --- /dev/null +++ b/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx @@ -0,0 +1,151 @@ +import { updateConfigItemProperties } 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; + existingProperties?: Property[] | null; +}; + +type AddPropertyForm = { + name: string; + valueType: "text" | "value"; + text: string; + value: string; +}; + +export default function AddConfigPropertyModal({ + configId, + isOpen, + onClose, + onAdded, + existingProperties +}: Props) { + const { user } = useUser(); + + const userProperties = + existingProperties?.filter( + (property) => + property.creator_type === "person" && property.created_by === user?.id + ) ?? []; + + return ( + + + initialValues={{ name: "", valueType: "text", text: "", value: "" }} + 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 = + values.valueType === "value" + ? { name: values.name, value: Number(values.value) } + : { name: values.name, text: values.text }; + + const incoming = [ + ...userProperties.filter( + (property) => property.name !== values.name + ), + newProperty + ]; + + const result = await updateConfigItemProperties( + configId, + "person", + user.id, + incoming + ); + 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..3590243175 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,45 @@ export function ConfigDetails({ configId }: Props) { +
+ + Properties + +
+ } + onClick={() => setIsManagePropertiesOpen(true)} + /> + } + onClick={() => setIsAddPropertyOpen(true)} + /> +
+ + +
+ setIsAddPropertyOpen(false)} + existingProperties={configDetails.properties} + onAdded={() => refetchConfig()} + /> + + setIsManagePropertiesOpen(false)} + existingProperties={configDetails.properties} + onChanged={() => refetchConfig()} + /> + {!isCostsEmpty(configDetails) && ( void; + onChanged?: (properties?: Property[]) => void; + existingProperties?: Property[] | null; +}; + +export default function ManageConfigPropertiesModal({ + configId, + isOpen, + onClose, + onChanged, + existingProperties +}: Props) { + const { user } = useUser(); + + const userProperties = + existingProperties?.filter( + (property) => + property.creator_type === "person" && property.created_by === user?.id + ) ?? []; + + const deleteProperty = async (property: Property) => { + if (!user?.id) { + toastError("Could not determine current user"); + return; + } + + try { + const result = await deleteConfigItemProperty( + configId, + "person", + user.id, + property.name + ); + 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) => ( +
+
+
+ {property.name} +
+
+ {String(property.text ?? property.value ?? "")} +
+
+ } + 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 []; } From 330ad72606d9ebd161cf8250025cb56009759544 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 5 May 2026 20:22:47 +0545 Subject: [PATCH 2/3] chore: use new config_properties table --- src/api/services/configs.ts | 102 ++++++++++++++---- src/api/types/topology.ts | 6 ++ .../Sidebar/AddConfigPropertyModal.tsx | 49 ++++----- .../Configs/Sidebar/ConfigDetails.tsx | 1 - 4 files changed, 111 insertions(+), 47 deletions(-) diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 513cfcdd4d..c6b4934815 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -165,27 +165,88 @@ export const getConfig = (id: string) => ConfigDB.get(`/config_detail?id=eq.${id}&select=*`) ); -export type UpdateConfigItemPropertiesResponse = { +export type ConfigItemPropertiesResponse = { changed: boolean; properties: Property[]; }; -export const updateConfigItemProperties = async ( +type ConfigPropertyRow = { + config_id: string; + creator_type: "person" | "scraper"; + created_by: string; + name: string; + label?: string; + tooltip?: string; + icon?: string; + type?: string; + color?: string; + order?: number; + headline?: boolean; + hidden?: boolean; + text?: string; + value?: number | string; + unit?: string; + max?: number; + min?: number; + status?: string; + last_transition?: string; + link?: string; + link_label?: string; +}; + +const getConfigProperties = async (configId: string) => { + const result = await getConfig(configId); + return result.data?.[0]?.properties ?? []; +}; + +const toConfigPropertyRow = ( configId: string, creatorType: "person" | "scraper", createdBy: string, - properties: Property[] -) => { - const res = await ConfigDB.post( - "/rpc/update_config_item_properties", - { - p_config_id: configId, - p_creator_type: creatorType, - p_created_by: createdBy, - p_properties: properties - } + property: Property +): ConfigPropertyRow => { + const firstLink = property.links?.[0]; + + return { + config_id: configId, + creator_type: creatorType, + created_by: createdBy, + name: property.name, + label: property.label, + tooltip: property.tooltip, + icon: property.icon, + type: property.type, + color: property.color, + 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: property.link ?? property.url ?? firstLink?.url, + link_label: property.link_label ?? firstLink?.label + }; +}; + +export const createConfigItemProperty = async ( + configId: string, + creatorType: "person" | "scraper", + createdBy: string, + property: Property +): Promise => { + await ConfigDB.post( + "config_properties", + toConfigPropertyRow(configId, creatorType, createdBy, property), + { headers: { Prefer: "return=minimal" } } ); - return res.data?.[0]; + + return { changed: true, properties: await getConfigProperties(configId) }; }; export const deleteConfigItemProperty = async ( @@ -194,16 +255,13 @@ export const deleteConfigItemProperty = async ( createdBy: string, propertyName: string ) => { - const res = await ConfigDB.post( - "/rpc/delete_config_item_property", - { - p_config_id: configId, - p_creator_type: creatorType, - p_created_by: createdBy, - p_property_name: propertyName - } + await ConfigDB.patch( + `config_properties?config_id=eq.${configId}&creator_type=eq.${creatorType}&created_by=eq.${createdBy}&name=eq.${encodeURIComponent(propertyName)}&deleted_at=is.null`, + { deleted_at: new Date().toISOString() }, + { headers: { Prefer: "return=minimal" } } ); - return res.data?.[0]; + + return { changed: true, properties: await getConfigProperties(configId) }; }; export type ConfigsTagList = { diff --git a/src/api/types/topology.ts b/src/api/types/topology.ts index b62433b0e4..20de1ce1c2 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 index 89aef4ef62..c46b5208d4 100644 --- a/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx +++ b/src/components/Configs/Sidebar/AddConfigPropertyModal.tsx @@ -1,4 +1,4 @@ -import { updateConfigItemProperties } from "@flanksource-ui/api/services/configs"; +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 { @@ -15,7 +15,6 @@ type Props = { isOpen: boolean; onClose: () => void; onAdded?: (properties?: Property[]) => void; - existingProperties?: Property[] | null; }; type AddPropertyForm = { @@ -23,23 +22,18 @@ type AddPropertyForm = { valueType: "text" | "value"; text: string; value: string; + link: string; + link_label: string; }; export default function AddConfigPropertyModal({ configId, isOpen, onClose, - onAdded, - existingProperties + onAdded }: Props) { const { user } = useUser(); - const userProperties = - existingProperties?.filter( - (property) => - property.creator_type === "person" && property.created_by === user?.id - ) ?? []; - return ( - initialValues={{ name: "", valueType: "text", text: "", value: "" }} + initialValues={{ + name: "", + valueType: "text", + text: "", + value: "", + link: "", + link_label: "" + }} onSubmit={async (values, formik) => { if (!user?.id) { toastError("Could not determine current user"); @@ -74,23 +75,21 @@ export default function AddConfigPropertyModal({ } try { - const newProperty: Property = - values.valueType === "value" - ? { name: values.name, value: Number(values.value) } - : { name: values.name, text: values.text }; + 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 incoming = [ - ...userProperties.filter( - (property) => property.name !== values.name - ), - newProperty - ]; - - const result = await updateConfigItemProperties( + const result = await createConfigItemProperty( configId, "person", user.id, - incoming + newProperty ); toastSuccess("Property added"); onAdded?.(result?.properties); @@ -127,6 +126,8 @@ export default function AddConfigPropertyModal({ ) : ( )} + +