From 9eaacc0b2d16cf2e9b0e7f4686806ab48743478e Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Sun, 22 Mar 2026 00:48:09 +0545 Subject: [PATCH 01/72] feat(catalog): add global access summary page (#2939) * feat(catalog): add global access summary page with filters * feat(catalog): add grouped access view with drilldown fixes Add grouped-by-user and grouped-by-config access summary queries and tables. Support row-click drilldown into flat access filters using tristate-compatible URL values, hide Group By in drilldown mode, and preserve configType. Fix dropdown layering by portaling SelectDropdown menus and normalize encoded tristate keys so user/role selections render correctly from URL params. * refactor(config-access): extract access page into modular components * fix(config-access): scope filter facets to active context * refactor(api): split config access services from configs * refactor(config-access): use shared tristate helpers * refactor(config-access): centralize catalog access URL state * feat(config-access): add user type filter and drop access column * fix(config-access): scope grouped queries and refresh facets * fix(access): remove invalid defaultSorting on access_count column * refactor(access): use single RPC for filter dropdown options Replace 4 separate queries that fetched the full config_access_summary view and deduplicated client-side with a single call to the new config_access_filter_options RPC that returns distinct values server-side. Depends on flanksource/duty#1841 * refactor(access): filter by external_user_id instead of user name - Drill-down and dropdown filters now use external_user_id (stable UUID) - User dropdown shows name (email) as label, external_user_id as value - URL param key changed from 'user' to 'external_user_id' - Flat table user cell filters by external_user_id - Types updated to include external_user_id on ConfigAccessSummary and ConfigAccessSummaryByUser * fix: Keep manualPageCount synced with the active page size. --- src/App.tsx | 15 ++ .../useAllConfigAccessSummaryQuery.ts | 69 +++++ .../useConfigAccessGroupedQuery.ts | 83 ++++++ .../query-hooks/useConfigAccessLogsQuery.tsx | 2 +- .../useConfigAccessSummaryQuery.tsx | 4 +- src/api/services/configAccess.ts | 251 ++++++++++++++++++ src/api/services/configs.ts | 36 +-- src/api/types/configs.ts | 28 ++ .../Configs/Access/ConfigAccessFilters.tsx | 247 +++++++++++++++++ .../Access/ConfigAccessGroupByDropdown.tsx | 42 +++ .../Access/tables/ConfigAccessFlatTable.tsx | 101 +++++++ .../ConfigAccessGroupedByCatalogTable.tsx | 103 +++++++ .../tables/ConfigAccessGroupedByUserTable.tsx | 103 +++++++ .../Configs/Access/tables/cells.tsx | 207 +++++++++++++++ src/components/Configs/Access/utils.ts | 12 + src/components/Configs/ConfigPageTabs.tsx | 6 + src/hooks/useCatalogAccessUrlState.ts | 174 ++++++++++++ src/lib/tristate.ts | 84 ++++++ src/pages/config/ConfigAccessPage.tsx | 142 ++++++++++ .../details/ConfigDetailsAccessPage.tsx | 16 -- src/ui/Dropdowns/SelectDropdown.tsx | 16 ++ src/ui/Dropdowns/TristateReactSelect.tsx | 79 ++++-- 22 files changed, 1752 insertions(+), 68 deletions(-) create mode 100644 src/api/query-hooks/useAllConfigAccessSummaryQuery.ts create mode 100644 src/api/query-hooks/useConfigAccessGroupedQuery.ts create mode 100644 src/api/services/configAccess.ts create mode 100644 src/components/Configs/Access/ConfigAccessFilters.tsx create mode 100644 src/components/Configs/Access/ConfigAccessGroupByDropdown.tsx create mode 100644 src/components/Configs/Access/tables/ConfigAccessFlatTable.tsx create mode 100644 src/components/Configs/Access/tables/ConfigAccessGroupedByCatalogTable.tsx create mode 100644 src/components/Configs/Access/tables/ConfigAccessGroupedByUserTable.tsx create mode 100644 src/components/Configs/Access/tables/cells.tsx create mode 100644 src/components/Configs/Access/utils.ts create mode 100644 src/hooks/useCatalogAccessUrlState.ts create mode 100644 src/lib/tristate.ts create mode 100644 src/pages/config/ConfigAccessPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 1eb219a13b..cb70bfd0b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -139,6 +139,12 @@ const ConfigChangesPage = dynamic( ) ); +const ConfigAccessPage = dynamic( + import("@flanksource-ui/pages/config/ConfigAccessPage").then( + (mod) => mod.ConfigAccessPage + ) +); + const PlaybookRunsPage = dynamic( import("@flanksource-ui/pages/playbooks/PlaybookRunsPage").then( (mod) => mod.default @@ -909,6 +915,15 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { true )} /> + , + tables.database, + "read", + true + )} + /> +>; + +export function useAllConfigAccessSummaryQuery( + queryOptions: UseQueryOptions = { + enabled: true, + keepPreviousData: true + } +) { + const { configType, filters } = useCatalogAccessUrlState(); + + const arbitraryFilter = useMemo( + () => + Object.fromEntries( + Object.entries(filters).filter(([, value]) => Boolean(value)) + ) as Record, + [filters] + ); + + const { pageIndex, pageSize } = useReactTablePaginationState({ + paramPrefix: CATALOG_ACCESS_FLAT_TABLE_PREFIX, + defaultPageSize: 50 + }); + + const [sortBy] = useReactTableSortState({ + paramPrefix: CATALOG_ACCESS_FLAT_TABLE_PREFIX, + defaultSorting: [{ id: "created_at", desc: true }] + }); + + const sortField = sortBy[0]?.id ?? "created_at"; + const sortOrder = sortBy[0]?.desc ? "desc" : "asc"; + + return useQuery({ + queryKey: [ + "config", + "access-summary", + "all", + { + configType, + pageIndex, + pageSize, + sortField, + sortOrder, + arbitraryFilter + } + ], + queryFn: () => + getConfigAccessSummary({ + configType, + pageIndex, + pageSize, + sortBy: sortField, + sortOrder, + arbitraryFilter + }), + ...queryOptions + }); +} diff --git a/src/api/query-hooks/useConfigAccessGroupedQuery.ts b/src/api/query-hooks/useConfigAccessGroupedQuery.ts new file mode 100644 index 0000000000..856d497923 --- /dev/null +++ b/src/api/query-hooks/useConfigAccessGroupedQuery.ts @@ -0,0 +1,83 @@ +import { + CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX, + CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX, + useCatalogAccessUrlState +} from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; +import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactTableSortState"; +import { useQuery } from "@tanstack/react-query"; +import { + getConfigAccessSummaryByUser, + getConfigAccessSummaryByConfig +} from "../services/configAccess"; + +function useGroupedPaginationAndSort(paramPrefix: string) { + const { configType } = useCatalogAccessUrlState(); + + const { pageIndex, pageSize } = useReactTablePaginationState({ + paramPrefix, + defaultPageSize: 50 + }); + + const [sortBy] = useReactTableSortState({ + paramPrefix, + defaultSorting: [{ id: "access_count", desc: true }] + }); + + const sortField = sortBy[0]?.id ?? "access_count"; + const sortOrder = (sortBy[0]?.desc ? "desc" : "asc") as "asc" | "desc"; + + return { configType, pageIndex, pageSize, sortField, sortOrder }; +} + +export function useConfigAccessGroupedByUserQuery() { + const { configType, pageIndex, pageSize, sortField, sortOrder } = + useGroupedPaginationAndSort(CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX); + + return useQuery( + [ + "config", + "access-summary", + "grouped", + "user", + { configType, pageIndex, pageSize, sortField, sortOrder } + ], + () => + getConfigAccessSummaryByUser({ + configType, + pageIndex, + pageSize, + sortBy: sortField, + sortOrder + }), + { + keepPreviousData: true + } + ); +} + +export function useConfigAccessGroupedByConfigQuery() { + const { configType, pageIndex, pageSize, sortField, sortOrder } = + useGroupedPaginationAndSort(CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX); + + return useQuery( + [ + "config", + "access-summary", + "grouped", + "config", + { configType, pageIndex, pageSize, sortField, sortOrder } + ], + () => + getConfigAccessSummaryByConfig({ + configType, + pageIndex, + pageSize, + sortBy: sortField, + sortOrder + }), + { + keepPreviousData: true + } + ); +} diff --git a/src/api/query-hooks/useConfigAccessLogsQuery.tsx b/src/api/query-hooks/useConfigAccessLogsQuery.tsx index c5c27af6ca..ddbf3df32d 100644 --- a/src/api/query-hooks/useConfigAccessLogsQuery.tsx +++ b/src/api/query-hooks/useConfigAccessLogsQuery.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { getConfigAccessLogs } from "../services/configs"; +import { getConfigAccessLogs } from "../services/configAccess"; export default function useConfigAccessLogsQuery(configId: string | undefined) { return useQuery({ diff --git a/src/api/query-hooks/useConfigAccessSummaryQuery.tsx b/src/api/query-hooks/useConfigAccessSummaryQuery.tsx index 5fe049cca7..9916d93e16 100644 --- a/src/api/query-hooks/useConfigAccessSummaryQuery.tsx +++ b/src/api/query-hooks/useConfigAccessSummaryQuery.tsx @@ -1,12 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { getConfigAccessSummary } from "../services/configs"; +import { getConfigAccessSummary } from "../services/configAccess"; export default function useConfigAccessSummaryQuery( configId: string | undefined ) { return useQuery({ queryKey: ["config", "access-summary", configId], - queryFn: () => getConfigAccessSummary(configId!), + queryFn: () => getConfigAccessSummary({ configId: configId! }), enabled: !!configId, keepPreviousData: true }); diff --git a/src/api/services/configAccess.ts b/src/api/services/configAccess.ts new file mode 100644 index 0000000000..becaaa6eae --- /dev/null +++ b/src/api/services/configAccess.ts @@ -0,0 +1,251 @@ +import { tristateOutputToQueryParamValue } from "@flanksource-ui/lib/tristate"; +import { ConfigDB } from "../axios"; +import { resolvePostGrestRequestWithPagination } from "../resolve"; +import { + ConfigAccessLog, + ConfigAccessSummary, + ConfigAccessSummaryByConfig, + ConfigAccessSummaryByUser +} from "../types/configs"; + +export type GetConfigAccessSummaryParams = { + configId?: string; + configType?: string; + pageIndex?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; + arbitraryFilter?: Record; +}; + +export type ConfigAccessSummaryFilterParams = Pick< + GetConfigAccessSummaryParams, + "configType" | "arbitraryFilter" +>; + +function applyConfigAccessSummaryFilters( + queryParams: URLSearchParams, + { configType, arbitraryFilter }: ConfigAccessSummaryFilterParams = {} +) { + if (configType) { + queryParams.set("config_type", `eq.${configType}`); + } + + if (arbitraryFilter) { + Object.entries(arbitraryFilter).forEach(([key, value]) => { + const filterExpression = tristateOutputToQueryParamValue(value); + + if (filterExpression) { + queryParams.set(`${key}.filter`, filterExpression); + } + }); + } +} + +export const getConfigAccessSummary = ({ + configId, + configType, + pageIndex, + pageSize, + sortBy = "user", + sortOrder = "asc", + arbitraryFilter +}: GetConfigAccessSummaryParams = {}) => { + const queryParams = new URLSearchParams(); + + queryParams.set( + "select", + "config_id,config_name,config_type,external_user_id,user,email,role,user_type,external_group_id,last_signed_in_at,last_reviewed_at,created_at" + ); + + if (configId) { + queryParams.set("config_id", `eq.${configId}`); + } + + applyConfigAccessSummaryFilters(queryParams, { configType, arbitraryFilter }); + + if (pageIndex !== undefined && pageSize !== undefined) { + queryParams.set("limit", pageSize.toString()); + queryParams.set("offset", `${pageIndex * pageSize}`); + } + + const sortableFieldMap: Record = { + config: "config_name", + config_name: "config_name", + config_type: "config_type", + user: "user", + email: "email", + role: "role", + user_type: "user_type", + access: "external_group_id", + external_group_id: "external_group_id", + last_signed_in_at: "last_signed_in_at", + last_reviewed_at: "last_reviewed_at", + created_at: "created_at" + }; + + const safeSortBy = sortableFieldMap[sortBy] ?? "user"; + queryParams.set("order", `${safeSortBy}.${sortOrder}`); + + return resolvePostGrestRequestWithPagination( + ConfigDB.get(`/config_access_summary?${queryParams.toString()}`, { + headers: { + Prefer: "count=exact" + } + }) + ); +}; + +export type GetConfigAccessGroupedParams = { + configType?: string; + pageIndex?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; +}; + +export const getConfigAccessSummaryByUser = ({ + configType, + pageIndex, + pageSize, + sortBy = "access_count", + sortOrder = "desc" +}: GetConfigAccessGroupedParams = {}) => { + const queryParams = new URLSearchParams(); + + queryParams.set( + "select", + "external_user_id,user,email,access_count,distinct_roles,distinct_configs,last_signed_in_at,latest_grant" + ); + + applyConfigAccessSummaryFilters(queryParams, { configType }); + + if (pageIndex !== undefined && pageSize !== undefined) { + queryParams.set("limit", pageSize.toString()); + queryParams.set("offset", `${pageIndex * pageSize}`); + } + + const sortableFieldMap: Record = { + user: "user", + email: "email", + access_count: "access_count", + distinct_roles: "distinct_roles", + distinct_configs: "distinct_configs", + last_signed_in_at: "last_signed_in_at", + latest_grant: "latest_grant" + }; + + const safeSortBy = sortableFieldMap[sortBy] ?? "access_count"; + queryParams.set("order", `${safeSortBy}.${sortOrder}`); + + return resolvePostGrestRequestWithPagination( + ConfigDB.get(`/config_access_summary_by_user?${queryParams.toString()}`, { + headers: { + Prefer: "count=exact" + } + }) + ); +}; + +export const getConfigAccessSummaryByConfig = ({ + configType, + pageIndex, + pageSize, + sortBy = "access_count", + sortOrder = "desc" +}: GetConfigAccessGroupedParams = {}) => { + const queryParams = new URLSearchParams(); + + queryParams.set( + "select", + "config_id,config_name,config_type,access_count,distinct_users,distinct_roles,last_signed_in_at,latest_grant" + ); + + applyConfigAccessSummaryFilters(queryParams, { configType }); + + if (pageIndex !== undefined && pageSize !== undefined) { + queryParams.set("limit", pageSize.toString()); + queryParams.set("offset", `${pageIndex * pageSize}`); + } + + const sortableFieldMap: Record = { + config_name: "config_name", + config_type: "config_type", + access_count: "access_count", + distinct_users: "distinct_users", + distinct_roles: "distinct_roles", + last_signed_in_at: "last_signed_in_at", + latest_grant: "latest_grant" + }; + + const safeSortBy = sortableFieldMap[sortBy] ?? "access_count"; + queryParams.set("order", `${safeSortBy}.${sortOrder}`); + + return resolvePostGrestRequestWithPagination( + ConfigDB.get(`/config_access_summary_by_config?${queryParams.toString()}`, { + headers: { + Prefer: "count=exact" + } + }) + ); +}; + +export const getConfigAccessLogs = (configId: string) => + resolvePostGrestRequestWithPagination( + ConfigDB.get( + `/config_access_logs?config_id=eq.${encodeURIComponent( + configId + )}&select=*,external_users(name,user_email:email)&order=${encodeURIComponent( + "created_at.desc" + )}`, + { + headers: { + Prefer: "count=exact" + } + } + ) + ); + +export type ConfigAccessFilterOptionsParams = { + configId?: string; + configType?: string; + userId?: string; + role?: string; + userType?: string; +}; + +export type ConfigAccessFilterOptions = { + catalogs: { config_id: string; config_name: string; config_type: string }[]; + users: { + external_user_id: string; + user: string; + email?: string | null; + }[]; + roles: { role: string }[]; + user_types: { user_type: string }[]; +}; + +const emptyFilterOptions: ConfigAccessFilterOptions = { + catalogs: [], + users: [], + roles: [], + user_types: [] +}; + +export const getConfigAccessFilterOptions = async ( + params: ConfigAccessFilterOptionsParams = {} +): Promise => { + const queryParams = new URLSearchParams(); + + if (params.configId) queryParams.set("p_config_id", params.configId); + if (params.configType) queryParams.set("p_config_type", params.configType); + if (params.userId) queryParams.set("p_user_id", params.userId); + if (params.role) queryParams.set("p_role", params.role); + if (params.userType) queryParams.set("p_user_type", params.userType); + + const res = await ConfigDB.get( + `/rpc/config_access_filter_options?${queryParams.toString()}` + ); + + return res.data ?? emptyFilterOptions; +}; diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 7e293fd943..d61dac7410 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -8,8 +8,6 @@ import { resolvePostGrestRequestWithPagination } from "../resolve"; import { PaginationInfo } from "../types/common"; import { ConfigAnalysis, - ConfigAccessSummary, - ConfigAccessLog, ConfigChange, ConfigHealthCheckView, ConfigItem, @@ -17,6 +15,8 @@ import { ConfigTypeRelationships } from "../types/configs"; +export * from "./configAccess"; + export const getAllConfigs = () => resolvePostGrestRequestWithPagination(ConfigDB.get(`/configs`)); @@ -164,38 +164,6 @@ export const getConfig = (id: string) => ConfigDB.get(`/config_detail?id=eq.${id}&select=*`) ); -export const getConfigAccessSummary = (configId: string) => - resolvePostGrestRequestWithPagination( - ConfigDB.get( - `/config_access_summary?config_id=eq.${encodeURIComponent( - configId - )}&select=user,email,role,user_type,external_group_id,last_signed_in_at,last_reviewed_at,created_at&order=${encodeURIComponent( - "user.asc" - )}`, - { - headers: { - Prefer: "count=exact" - } - } - ) - ); - -export const getConfigAccessLogs = (configId: string) => - resolvePostGrestRequestWithPagination( - ConfigDB.get( - `/config_access_logs?config_id=eq.${encodeURIComponent( - configId - )}&select=*,external_users(name,user_email:email)&order=${encodeURIComponent( - "created_at.desc" - )}`, - { - headers: { - Prefer: "count=exact" - } - } - ) - ); - export type ConfigsTagList = { key: string; value: any; diff --git a/src/api/types/configs.ts b/src/api/types/configs.ts index 8c272d7732..09c4d668e1 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -98,6 +98,10 @@ export interface ConfigItemGraphData extends ConfigItem { } export interface ConfigAccessSummary { + config_id?: string; + config_name?: string | null; + config_type?: string | null; + external_user_id: string; user: string; email: string; role?: string | null; @@ -108,6 +112,30 @@ export interface ConfigAccessSummary { created_at: string; } +export interface ConfigAccessSummaryByUser { + external_user_id: string; + user: string; + email: string; + access_count: number; + distinct_roles: number; + distinct_configs: number; + last_signed_in_at?: string | null; + latest_grant?: string | null; +} + +export interface ConfigAccessSummaryByConfig { + config_id: string; + config_name: string; + config_type: string; + access_count: number; + distinct_users: number; + distinct_roles: number; + last_signed_in_at?: string | null; + latest_grant?: string | null; +} + +export type ConfigAccessGroupBy = "user" | "config"; + export interface ConfigAccessLog { config_id: string; external_user_id: string; diff --git a/src/components/Configs/Access/ConfigAccessFilters.tsx b/src/components/Configs/Access/ConfigAccessFilters.tsx new file mode 100644 index 0000000000..504383ead6 --- /dev/null +++ b/src/components/Configs/Access/ConfigAccessFilters.tsx @@ -0,0 +1,247 @@ +import { + ConfigAccessFilterOptions, + ConfigAccessFilterOptionsParams, + getConfigAccessFilterOptions +} from "@flanksource-ui/api/services/configAccess"; +import FormikFilterForm from "@flanksource-ui/components/Forms/FormikFilterForm"; +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useQuery } from "@tanstack/react-query"; +import { useField } from "formik"; +import { useMemo } from "react"; +import { useCatalogAccessUrlState } from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import { + decodeTristateKey, + parseTristateKeyState +} from "@flanksource-ui/lib/tristate"; +import { paramsToReset } from "./utils"; + +const filterCacheOptions = { + staleTime: 10 * 60 * 1000, + cacheTime: 60 * 60 * 1000, + refetchOnWindowFocus: false +} as const; + +/** + * Extracts the plain value from a tristate-encoded URL param (e.g. "someValue:1") + * Only returns the value for "include" state (1). + */ +function extractIncludeValue( + tristateParam: string | undefined +): string | undefined { + if (!tristateParam) return undefined; + const parsed = parseTristateKeyState(tristateParam); + if (!parsed || parsed.state !== 1) return undefined; + return decodeTristateKey(parsed.key); +} + +/** + * Shared hook that fetches all filter options via a single RPC call. + * The RPC handles faceted exclusion server-side: each facet is computed + * without its own filter so that selecting a value in one dropdown + * does not remove it from its own option list. + */ +function useConfigAccessFilterOptions() { + const { configType, filters } = useCatalogAccessUrlState(); + + const params = useMemo(() => { + const result: ConfigAccessFilterOptionsParams = {}; + if (configType) result.configType = configType; + const configId = extractIncludeValue(filters.config_id); + if (configId) result.configId = configId; + const userId = extractIncludeValue(filters.external_user_id); + if (userId) result.userId = userId; + const role = extractIncludeValue(filters.role); + if (role) result.role = role; + const userType = extractIncludeValue(filters.user_type); + if (userType) result.userType = userType; + return result; + }, [configType, filters]); + + return useQuery({ + queryKey: ["config", "access-summary", "filter-options", params], + queryFn: () => getConfigAccessFilterOptions(params), + ...filterCacheOptions + }); +} + +function useCatalogOptions(data: ConfigAccessFilterOptions | undefined) { + return useMemo( + () => + (data?.catalogs ?? []).map((item) => ({ + id: item.config_id, + label: item.config_name, + value: item.config_id + })), + [data?.catalogs] + ); +} + +function useUserOptions(data: ConfigAccessFilterOptions | undefined) { + return useMemo( + () => + (data?.users ?? []).map((item) => ({ + id: item.external_user_id, + label: item.email ? `${item.user} (${item.email})` : item.user, + value: item.external_user_id + })), + [data?.users] + ); +} + +function useRoleOptions(data: ConfigAccessFilterOptions | undefined) { + return useMemo( + () => + (data?.roles ?? []).map((item) => ({ + id: item.role, + label: item.role, + value: item.role + })), + [data?.roles] + ); +} + +function useTypeOptions(data: ConfigAccessFilterOptions | undefined) { + return useMemo( + () => + (data?.user_types ?? []).map((item) => ({ + id: item.user_type, + label: item.user_type, + value: item.user_type + })), + [data?.user_types] + ); +} + +function CatalogDropdown({ + data, + isLoading +}: { + data: ConfigAccessFilterOptions | undefined; + isLoading: boolean; +}) { + const [field] = useField({ name: "config_id" }); + const options = useCatalogOptions(data); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name: "config_id", value } }); + } else { + field.onChange({ target: { name: "config_id", value: undefined } }); + } + }} + label="Catalog" + /> + ); +} + +function UserDropdown({ + data, + isLoading +}: { + data: ConfigAccessFilterOptions | undefined; + isLoading: boolean; +}) { + const [field] = useField({ name: "external_user_id" }); + const options = useUserOptions(data); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name: "external_user_id", value } }); + } else { + field.onChange({ + target: { name: "external_user_id", value: undefined } + }); + } + }} + label="User" + /> + ); +} + +function RoleDropdown({ + data, + isLoading +}: { + data: ConfigAccessFilterOptions | undefined; + isLoading: boolean; +}) { + const [field] = useField({ name: "role" }); + const options = useRoleOptions(data); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name: "role", value } }); + } else { + field.onChange({ target: { name: "role", value: undefined } }); + } + }} + label="Role" + /> + ); +} + +function TypeDropdown({ + data, + isLoading +}: { + data: ConfigAccessFilterOptions | undefined; + isLoading: boolean; +}) { + const [field] = useField({ name: "user_type" }); + const options = useTypeOptions(data); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name: "user_type", value } }); + } else { + field.onChange({ target: { name: "user_type", value: undefined } }); + } + }} + label="Type" + /> + ); +} + +export function ConfigAccessFilters() { + const { data, isLoading } = useConfigAccessFilterOptions(); + + return ( + +
+ + + + +
+
+ ); +} diff --git a/src/components/Configs/Access/ConfigAccessGroupByDropdown.tsx b/src/components/Configs/Access/ConfigAccessGroupByDropdown.tsx new file mode 100644 index 0000000000..46d1d85869 --- /dev/null +++ b/src/components/Configs/Access/ConfigAccessGroupByDropdown.tsx @@ -0,0 +1,42 @@ +import { CatalogAccessMode } from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import { + GroupByOptions, + MultiSelectDropdown +} from "@flanksource-ui/ui/Dropdowns/MultiSelectDropdown"; + +const groupByOptions: (GroupByOptions & { value: CatalogAccessMode })[] = [ + { label: "None", value: "flat" }, + { label: "User", value: "group-user" }, + { label: "Catalog", value: "group-config" } +]; + +type ConfigAccessGroupByDropdownProps = { + mode: CatalogAccessMode; + onChange: (mode: CatalogAccessMode) => void; +}; + +export function ConfigAccessGroupByDropdown({ + mode, + onChange +}: ConfigAccessGroupByDropdownProps) { + const value = + groupByOptions.find((option) => option.value === mode) ?? groupByOptions[0]; + + return ( + { + const option = selected as GroupByOptions | null; + onChange((option?.value as CatalogAccessMode) ?? "flat"); + }} + label="Group By" + className="w-44" + minMenuWidth="180px" + defaultValue="None" + /> + ); +} diff --git a/src/components/Configs/Access/tables/ConfigAccessFlatTable.tsx b/src/components/Configs/Access/tables/ConfigAccessFlatTable.tsx new file mode 100644 index 0000000000..af5da69aa0 --- /dev/null +++ b/src/components/Configs/Access/tables/ConfigAccessFlatTable.tsx @@ -0,0 +1,101 @@ +import { ConfigAccessSummary } from "@flanksource-ui/api/types/configs"; +import { CATALOG_ACCESS_FLAT_TABLE_PREFIX } from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { + FlatConfigCell, + FlatLastSignedInCell, + FlatOptionalDateCell, + FlatRoleCell, + FlatTypeCell, + FlatUserCell +} from "./cells"; + +type ConfigAccessFlatTableProps = { + data: ConfigAccessSummary[]; + isLoading?: boolean; + isRefetching?: boolean; + totalRecords: number; +}; + +const primaryColumnWidth = 220; + +const flatColumns: MRT_ColumnDef[] = [ + { + header: "Catalog", + accessorKey: "config_name", + Cell: FlatConfigCell, + size: primaryColumnWidth + }, + { + header: "User", + accessorKey: "user", + Cell: FlatUserCell, + size: primaryColumnWidth + }, + { + header: "Role", + accessorKey: "role", + Cell: FlatRoleCell, + size: primaryColumnWidth + }, + { + header: "Type", + accessorKey: "user_type", + Cell: FlatTypeCell, + size: 90 + }, + { + header: "Last Signed In", + accessorKey: "last_signed_in_at", + Cell: FlatLastSignedInCell, + sortingFn: "datetime", + size: 120 + }, + { + header: "Last Reviewed", + accessorKey: "last_reviewed_at", + Cell: FlatOptionalDateCell, + sortingFn: "datetime", + size: 120 + }, + { + header: "Granted", + accessorKey: "created_at", + Cell: FlatOptionalDateCell, + sortingFn: "datetime", + size: 110 + } +]; + +export function ConfigAccessFlatTable({ + data, + isLoading = false, + isRefetching = false, + totalRecords +}: ConfigAccessFlatTableProps) { + const { pageSize } = useReactTablePaginationState({ + paramPrefix: CATALOG_ACCESS_FLAT_TABLE_PREFIX, + defaultPageSize: 50 + }); + + const totalPages = Math.ceil(totalRecords / pageSize); + + return ( + + ); +} diff --git a/src/components/Configs/Access/tables/ConfigAccessGroupedByCatalogTable.tsx b/src/components/Configs/Access/tables/ConfigAccessGroupedByCatalogTable.tsx new file mode 100644 index 0000000000..93b9253348 --- /dev/null +++ b/src/components/Configs/Access/tables/ConfigAccessGroupedByCatalogTable.tsx @@ -0,0 +1,103 @@ +import { useConfigAccessGroupedByConfigQuery } from "@flanksource-ui/api/query-hooks/useConfigAccessGroupedQuery"; +import { ConfigAccessSummaryByConfig } from "@flanksource-ui/api/types/configs"; +import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; +import { + CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX, + useCatalogAccessUrlState +} from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { useCallback } from "react"; +import { + GroupedByCatalogIdentityCell, + GroupedByCatalogLastSignedInCell, + GroupedByCatalogLatestGrantCell +} from "./cells"; + +const groupedByCatalogColumns: MRT_ColumnDef[] = [ + { + header: "Catalog", + accessorKey: "config_name", + Cell: GroupedByCatalogIdentityCell, + size: 300 + }, + { + header: "Users", + accessorKey: "distinct_users", + size: 100, + Cell: ({ cell }) => {cell.getValue()} + }, + { + header: "Roles", + accessorKey: "distinct_roles", + size: 100, + Cell: ({ cell }) => {cell.getValue()} + }, + { + header: "Last Signed In", + accessorKey: "last_signed_in_at", + sortingFn: "datetime", + size: 160, + Cell: GroupedByCatalogLastSignedInCell + }, + { + header: "Latest Grant", + accessorKey: "latest_grant", + sortingFn: "datetime", + size: 160, + Cell: GroupedByCatalogLatestGrantCell + } +]; + +export function ConfigAccessGroupedByCatalogTable() { + const { + actions: { drillDownByConfigId } + } = useCatalogAccessUrlState(); + + const { pageSize } = useReactTablePaginationState({ + paramPrefix: CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX, + defaultPageSize: 50 + }); + + const { data, isLoading, isRefetching, error } = + useConfigAccessGroupedByConfigQuery(); + + const rows = data?.data ?? []; + const totalRecords = data?.totalEntries ?? 0; + const totalPages = Math.ceil(totalRecords / pageSize); + + const handleRowClick = useCallback( + (row: ConfigAccessSummaryByConfig) => { + drillDownByConfigId(row.config_id); + }, + [drillDownByConfigId] + ); + + if (error) { + const errorMessage = + typeof error === "string" + ? error + : ((error as Record)?.message ?? + "Something went wrong"); + + return ; + } + + return ( + + ); +} diff --git a/src/components/Configs/Access/tables/ConfigAccessGroupedByUserTable.tsx b/src/components/Configs/Access/tables/ConfigAccessGroupedByUserTable.tsx new file mode 100644 index 0000000000..98d610f0db --- /dev/null +++ b/src/components/Configs/Access/tables/ConfigAccessGroupedByUserTable.tsx @@ -0,0 +1,103 @@ +import { useConfigAccessGroupedByUserQuery } from "@flanksource-ui/api/query-hooks/useConfigAccessGroupedQuery"; +import { ConfigAccessSummaryByUser } from "@flanksource-ui/api/types/configs"; +import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; +import { + CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX, + useCatalogAccessUrlState +} from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { useCallback } from "react"; +import { + GroupedByUserIdentityCell, + GroupedByUserLastSignedInCell, + GroupedByUserLatestGrantCell +} from "./cells"; + +const groupedByUserColumns: MRT_ColumnDef[] = [ + { + header: "User", + accessorKey: "user", + Cell: GroupedByUserIdentityCell, + size: 280 + }, + { + header: "Roles", + accessorKey: "distinct_roles", + size: 100, + Cell: ({ cell }) => {cell.getValue()} + }, + { + header: "Catalogs", + accessorKey: "distinct_configs", + size: 100, + Cell: ({ cell }) => {cell.getValue()} + }, + { + header: "Last Signed In", + accessorKey: "last_signed_in_at", + sortingFn: "datetime", + size: 160, + Cell: GroupedByUserLastSignedInCell + }, + { + header: "Latest Grant", + accessorKey: "latest_grant", + sortingFn: "datetime", + size: 160, + Cell: GroupedByUserLatestGrantCell + } +]; + +export function ConfigAccessGroupedByUserTable() { + const { + actions: { drillDownByUser } + } = useCatalogAccessUrlState(); + + const { pageSize } = useReactTablePaginationState({ + paramPrefix: CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX, + defaultPageSize: 50 + }); + + const { data, isLoading, isRefetching, error } = + useConfigAccessGroupedByUserQuery(); + + const rows = data?.data ?? []; + const totalRecords = data?.totalEntries ?? 0; + const totalPages = Math.ceil(totalRecords / pageSize); + + const handleRowClick = useCallback( + (row: ConfigAccessSummaryByUser) => { + drillDownByUser(row.external_user_id); + }, + [drillDownByUser] + ); + + if (error) { + const errorMessage = + typeof error === "string" + ? error + : ((error as Record)?.message ?? + "Something went wrong"); + + return ; + } + + return ( + + ); +} diff --git a/src/components/Configs/Access/tables/cells.tsx b/src/components/Configs/Access/tables/cells.tsx new file mode 100644 index 0000000000..d9f45e7e6c --- /dev/null +++ b/src/components/Configs/Access/tables/cells.tsx @@ -0,0 +1,207 @@ +import { + ConfigAccessSummary, + ConfigAccessSummaryByConfig, + ConfigAccessSummaryByUser +} from "@flanksource-ui/api/types/configs"; +import ConfigLink from "@flanksource-ui/components/Configs/ConfigLink/ConfigLink"; +import { ExternalUserCell } from "@flanksource-ui/components/Configs/ExternalUserCell"; +import { Age } from "@flanksource-ui/ui/Age"; +import { Badge } from "@flanksource-ui/ui/Badge/Badge"; +import FilterByCellValue from "@flanksource-ui/ui/DataTable/FilterByCellValue"; +import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; +import { paramsToReset } from "../utils"; + +export const FlatConfigCell = ({ row }: MRTCellProps) => { + const configId = row.original.config_id; + const configType = row.original.config_type; + const configName = row.original.config_name; + + if (!configId || !configType || !configName) { + return ; + } + + return ( + + + + ); +}; + +export const FlatUserCell = ({ row }: MRTCellProps) => { + const userName = row.original.user; + const user = { + name: userName, + user_email: row.original.email || null + }; + + if (!userName) { + return ; + } + + return ( + + + + ); +}; + +export const FlatRoleCell = ({ cell }: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ( + + {value} + + ); +}; + +export const FlatTypeCell = ({ cell }: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ( + + {value} + + ); +}; + +export const FlatLastSignedInCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return Never; + } + + return ; +}; + +export const FlatOptionalDateCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ; +}; + +export const GroupedByUserIdentityCell = ({ + row +}: MRTCellProps) => { + const user = { + name: row.original.user, + user_email: row.original.email || null + }; + + return ( +
+ + +
+ ); +}; + +export const GroupedByUserLastSignedInCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return Never; + } + + return ; +}; + +export const GroupedByUserLatestGrantCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ; +}; + +export const GroupedByCatalogIdentityCell = ({ + row +}: MRTCellProps) => { + const { config_id, config_type, config_name, access_count } = row.original; + + return ( +
+ + +
+ ); +}; + +export const GroupedByCatalogLastSignedInCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return Never; + } + + return ; +}; + +export const GroupedByCatalogLatestGrantCell = ({ + cell +}: MRTCellProps) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ; +}; diff --git a/src/components/Configs/Access/utils.ts b/src/components/Configs/Access/utils.ts new file mode 100644 index 0000000000..66811b010c --- /dev/null +++ b/src/components/Configs/Access/utils.ts @@ -0,0 +1,12 @@ +import { + CATALOG_ACCESS_FLAT_TABLE_PREFIX, + CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX, + CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX +} from "@flanksource-ui/hooks/useCatalogAccessUrlState"; + +export const paramsToReset = [ + "pageIndex", + `${CATALOG_ACCESS_FLAT_TABLE_PREFIX}__pageIndex`, + `${CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX}__pageIndex`, + `${CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX}__pageIndex` +]; diff --git a/src/components/Configs/ConfigPageTabs.tsx b/src/components/Configs/ConfigPageTabs.tsx index e355c8b614..bc588bab80 100644 --- a/src/components/Configs/ConfigPageTabs.tsx +++ b/src/components/Configs/ConfigPageTabs.tsx @@ -30,6 +30,12 @@ export default function ConfigPageTabs({ path: `/catalog/changes`, search: `${query}` }, + { + label: "Access", + key: "Access", + path: `/catalog/access`, + search: `${query}` + }, { label: "Insights", key: "Insights", diff --git a/src/hooks/useCatalogAccessUrlState.ts b/src/hooks/useCatalogAccessUrlState.ts new file mode 100644 index 0000000000..217f2790fb --- /dev/null +++ b/src/hooks/useCatalogAccessUrlState.ts @@ -0,0 +1,174 @@ +import { ConfigAccessGroupBy } from "@flanksource-ui/api/types/configs"; +import { toTriStateIncludeParamValue } from "@flanksource-ui/lib/tristate"; +import { useCallback, useMemo } from "react"; +import { usePrefixedSearchParams } from "./usePrefixedSearchParams"; + +export type CatalogAccessMode = "flat" | "group-user" | "group-config"; + +type CatalogAccessFilterKey = + | "config_id" + | "external_user_id" + | "role" + | "user_type"; + +type CatalogAccessFilters = Partial>; + +const FILTER_KEYS: CatalogAccessFilterKey[] = [ + "config_id", + "external_user_id", + "role", + "user_type" +]; + +export const CATALOG_ACCESS_FLAT_TABLE_PREFIX = "accessFlat"; +export const CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX = "accessGroupUser"; +export const CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX = "accessGroupConfig"; + +const CATALOG_ACCESS_PAGE_INDEX_KEYS = [ + `${CATALOG_ACCESS_FLAT_TABLE_PREFIX}__pageIndex`, + `${CATALOG_ACCESS_GROUP_USER_TABLE_PREFIX}__pageIndex`, + `${CATALOG_ACCESS_GROUP_CONFIG_TABLE_PREFIX}__pageIndex` +]; + +function hasDrillDownFilter(params: URLSearchParams) { + return FILTER_KEYS.some((key) => params.has(key)); +} + +function mapModeToGroupByParam(mode: CatalogAccessMode) { + switch (mode) { + case "group-user": + return "user"; + case "group-config": + return "config"; + default: + return "none"; + } +} + +function mapModeToGroupBy(mode: CatalogAccessMode): ConfigAccessGroupBy | null { + switch (mode) { + case "group-user": + return "user"; + case "group-config": + return "config"; + default: + return null; + } +} + +export function resolveCatalogAccessMode( + params: URLSearchParams +): CatalogAccessMode { + if (hasDrillDownFilter(params)) { + return "flat"; + } + + const mode = params.get("mode"); + if (mode === "flat" || mode === "group-user" || mode === "group-config") { + return mode; + } + + const groupBy = params.get("groupBy"); + if (groupBy === "none") { + return "flat"; + } + + if (groupBy === "user") { + return "group-user"; + } + + if (groupBy === "config") { + return "group-config"; + } + + return "group-config"; +} + +export function useCatalogAccessUrlState() { + const [params, setParams] = usePrefixedSearchParams(undefined, false); + + const configType = params.get("configType") ?? undefined; + + const filters = useMemo( + () => + FILTER_KEYS.reduce((acc, key) => { + const value = params.get(key) ?? undefined; + if (value) { + acc[key] = value; + } + return acc; + }, {} as CatalogAccessFilters), + [params] + ); + + const mode = useMemo(() => resolveCatalogAccessMode(params), [params]); + + const groupBy = useMemo(() => mapModeToGroupBy(mode), [mode]); + + const resetPageIndexes = useCallback((nextParams: URLSearchParams) => { + CATALOG_ACCESS_PAGE_INDEX_KEYS.forEach((key) => { + nextParams.delete(key); + }); + }, []); + + const setMode = useCallback( + (nextMode: CatalogAccessMode) => { + setParams((current) => { + const nextParams = new URLSearchParams(current); + + nextParams.set("mode", nextMode); + nextParams.set("groupBy", mapModeToGroupByParam(nextMode)); + + if (nextMode !== "flat") { + FILTER_KEYS.forEach((key) => { + nextParams.delete(key); + }); + } + + resetPageIndexes(nextParams); + + return nextParams; + }); + }, + [resetPageIndexes, setParams] + ); + + const setDrillDown = useCallback( + (key: CatalogAccessFilterKey, value: string) => { + setParams((current) => { + const nextParams = new URLSearchParams(current); + + nextParams.set("mode", "flat"); + nextParams.set("groupBy", "none"); + + FILTER_KEYS.forEach((filterKey) => { + if (filterKey !== key) { + nextParams.delete(filterKey); + } + }); + + nextParams.set(key, toTriStateIncludeParamValue(value)); + resetPageIndexes(nextParams); + + return nextParams; + }); + }, + [resetPageIndexes, setParams] + ); + + return { + configType, + mode, + groupBy, + isGrouped: mode !== "flat", + hasDrillDownFilter: hasDrillDownFilter(params), + filters, + actions: { + setMode, + drillDownByConfigId: (configId: string) => + setDrillDown("config_id", configId), + drillDownByUser: (userId: string) => + setDrillDown("external_user_id", userId) + } + }; +} diff --git a/src/lib/tristate.ts b/src/lib/tristate.ts new file mode 100644 index 0000000000..1e2bfa18bd --- /dev/null +++ b/src/lib/tristate.ts @@ -0,0 +1,84 @@ +/** + * Shared tristate protocol helpers. + * + * Internal format examples: + * - "value:1" + * - "value:-1" + * - "key____value:1,key____other:-1" + */ + +export type TriStateParsedValue = { + key: string; + state: number; +}; + +/** + * Parses a tristate key:state string where the key may contain colons. + * The state value (1, -1, or 0) is always after the LAST colon. + */ +export function parseTristateKeyState( + item: string +): TriStateParsedValue | null { + if (!item || typeof item !== "string") { + return null; + } + + const lastColonIndex = item.lastIndexOf(":"); + if (lastColonIndex === -1) { + return null; + } + + const key = item.slice(0, lastColonIndex); + const stateStr = item.slice(lastColonIndex + 1); + const state = parseInt(stateStr, 10); + + if (isNaN(state) || !key) { + return null; + } + + return { key, state }; +} + +export function encodeTristateKey(key: string) { + return key.replaceAll(",", "||||").replaceAll(":", "____"); +} + +export function decodeTristateKey(key: string) { + return key.replaceAll("____", ":").replaceAll("||||", ","); +} + +/** + * Converts an include-only value into tristate internal format. + */ +export function toTriStateIncludeParamValue(value: string) { + return `${encodeTristateKey(value)}:1`; +} + +/** + * Converts tristate internal output (key:1,key2:-1) into query param value + * format used by API filters (key,!key2). + */ +export function tristateOutputToQueryParamValue( + param: string | undefined, + encodeValue = false +) { + return param + ?.split(",") + .map((type) => { + const parsed = parseTristateKeyState(type); + if (!parsed) { + return null; + } + + const symbolFilter = parsed.state === -1 ? "!" : ""; + const filterValue = decodeTristateKey(parsed.key); + + if (encodeValue) { + return encodeURIComponent(`${symbolFilter}${filterValue}`); + } + + return `${symbolFilter}${filterValue}`; + }) + .filter(Boolean) + .join(","); +} diff --git a/src/pages/config/ConfigAccessPage.tsx b/src/pages/config/ConfigAccessPage.tsx new file mode 100644 index 0000000000..7cdf8c2fb4 --- /dev/null +++ b/src/pages/config/ConfigAccessPage.tsx @@ -0,0 +1,142 @@ +import { useAllConfigAccessSummaryQuery } from "@flanksource-ui/api/query-hooks/useAllConfigAccessSummaryQuery"; +import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; +import ConfigsTypeIcon from "@flanksource-ui/components/Configs/ConfigsTypeIcon"; +import { ConfigAccessFilters } from "@flanksource-ui/components/Configs/Access/ConfigAccessFilters"; +import { ConfigAccessGroupByDropdown } from "@flanksource-ui/components/Configs/Access/ConfigAccessGroupByDropdown"; +import { ConfigAccessFlatTable } from "@flanksource-ui/components/Configs/Access/tables/ConfigAccessFlatTable"; +import { ConfigAccessGroupedByCatalogTable } from "@flanksource-ui/components/Configs/Access/tables/ConfigAccessGroupedByCatalogTable"; +import { ConfigAccessGroupedByUserTable } from "@flanksource-ui/components/Configs/Access/tables/ConfigAccessGroupedByUserTable"; +import { InfoMessage } from "@flanksource-ui/components/InfoMessage"; +import { useCatalogAccessUrlState } from "@flanksource-ui/hooks/useCatalogAccessUrlState"; +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import { refreshButtonClickedTrigger } from "@flanksource-ui/ui/SlidingSideBar/SlidingSideBar"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAtom } from "jotai"; + +export function ConfigAccessPage() { + const [, setRefreshButtonClickedTrigger] = useAtom( + refreshButtonClickedTrigger + ); + const queryClient = useQueryClient(); + + const { + configType, + groupBy, + isGrouped, + hasDrillDownFilter, + mode, + actions: { setMode } + } = useCatalogAccessUrlState(); + + const { + data: flatAccessSummary, + isLoading: isLoadingFlat, + isRefetching: isRefetchingFlat, + error, + refetch: refetchFlat + } = useAllConfigAccessSummaryQuery({ + keepPreviousData: true, + enabled: !isGrouped + }); + + const rows = flatAccessSummary?.data ?? []; + const totalRecords = flatAccessSummary?.totalEntries ?? 0; + + const errorMessage = + typeof error === "string" + ? error + : ((error as Record)?.message ?? "Something went wrong"); + + return ( + <> + + + Catalog + , + + Access + , + ...(configType + ? [ + + + + ] + : []) + ]} + /> + } + onRefresh={() => { + setRefreshButtonClickedTrigger((prev) => prev + 1); + + if (isGrouped) { + void queryClient.invalidateQueries({ + queryKey: ["config", "access-summary", "grouped"] + }); + return; + } + + void refetchFlat(); + void queryClient.invalidateQueries({ + queryKey: ["config", "access-summary", "filter"] + }); + }} + loading={isGrouped ? false : isLoadingFlat || isRefetchingFlat} + contentClass="p-0 h-full flex flex-col flex-1" + > + + {!isGrouped && error ? ( + + ) : ( +
+ {!hasDrillDownFilter && ( +
+ +
+ )} + + {isGrouped ? ( +
+ {groupBy === "user" && } + {groupBy === "config" && ( + + )} +
+ ) : ( + <> + + +
+ +
+ + )} +
+ )} +
+
+ + ); +} diff --git a/src/pages/config/details/ConfigDetailsAccessPage.tsx b/src/pages/config/details/ConfigDetailsAccessPage.tsx index 7c8ddc60f3..8c24a11456 100644 --- a/src/pages/config/details/ConfigDetailsAccessPage.tsx +++ b/src/pages/config/details/ConfigDetailsAccessPage.tsx @@ -3,7 +3,6 @@ import { ConfigAccessSummary } from "@flanksource-ui/api/types/configs"; import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDetailsTabs"; import { ExternalUserCell } from "@flanksource-ui/components/Configs/ExternalUserCell"; import { Age } from "@flanksource-ui/ui/Age"; -import { Badge } from "@flanksource-ui/ui/Badge/Badge"; import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { MRT_ColumnDef } from "mantine-react-table"; @@ -43,15 +42,6 @@ const OptionalDateCell = ({ cell }: MRTCellProps) => { return ; }; -const AccessTypeCell = ({ row }: MRTCellProps) => { - const groupId = row.original.external_group_id; - if (groupId) { - return ; - } - - return ; -}; - export function ConfigDetailsAccessPage() { const { id } = useParams(); const { @@ -80,12 +70,6 @@ export function ConfigDetailsAccessPage() { Cell: RoleCell, size: 120 }, - { - header: "Access", - accessorKey: "external_group_id", - Cell: AccessTypeCell, - size: 120 - }, { header: "Last Signed In", accessorKey: "last_signed_in_at", diff --git a/src/ui/Dropdowns/SelectDropdown.tsx b/src/ui/Dropdowns/SelectDropdown.tsx index cbfe3feb6c..4369f2b96e 100644 --- a/src/ui/Dropdowns/SelectDropdown.tsx +++ b/src/ui/Dropdowns/SelectDropdown.tsx @@ -20,6 +20,9 @@ export default function SelectDropdown({ onChange = () => {}, ...props }: SelectDropdownProps) { + const menuPortalTarget = + typeof document !== "undefined" ? document.body : undefined; + return ( ( +
+ {option.scope.name} + {option.scope.namespace && ( + + {option.scope.namespace} + + )} +
+ )} + onChange={(opts) => setSelected([...opts])} + /> +
+ + +
+ + ) : ( + + initialValues={{ targets: initialTargets }} + onSubmit={(values) => { + setTargets(values.targets); + onClose(); + }} + > + {({ submitForm }) => ( +
+

+ Define custom targets to impersonate. +

+ +
+ + +
+ + )} + + )} + + + ); +} diff --git a/src/components/Scopes/Impersonation/__tests__/scopeImpersonationInterceptor.unit.test.ts b/src/components/Scopes/Impersonation/__tests__/scopeImpersonationInterceptor.unit.test.ts new file mode 100644 index 0000000000..ae6e753ee5 --- /dev/null +++ b/src/components/Scopes/Impersonation/__tests__/scopeImpersonationInterceptor.unit.test.ts @@ -0,0 +1,90 @@ +// ABOUTME: Tests that the axios interceptor attaches X-Flanksource-Scope header. +// ABOUTME: Validates header contains the resolved RLS payload format. + +import { IncidentCommander, Config, Catalog } from "@flanksource-ui/api/axios"; +import { ScopeDB } from "@flanksource-ui/api/types/scopes"; +import { + clearImpersonatedScopes, + setImpersonatedScopes +} from "../scopeImpersonationStore"; +import { AxiosHeaders, InternalAxiosRequestConfig } from "axios"; + +beforeEach(() => { + sessionStorage.clear(); +}); + +function makeScope(overrides: Partial = {}): ScopeDB { + return { + id: "scope-1", + name: "test", + targets: [], + source: "UI", + created_at: "", + updated_at: "", + ...overrides + }; +} + +// Run a request config through an axios instance's request interceptors +// without actually making an HTTP request +function runRequestInterceptors( + instance: typeof IncidentCommander +): InternalAxiosRequestConfig { + const handlers = (instance.interceptors.request as any).handlers as Array<{ + fulfilled: ( + config: InternalAxiosRequestConfig + ) => InternalAxiosRequestConfig; + }>; + + let config: InternalAxiosRequestConfig = { + headers: new AxiosHeaders() + }; + + for (const handler of handlers) { + if (handler.fulfilled) { + config = handler.fulfilled(config); + } + } + + return config; +} + +describe("scope impersonation interceptor", () => { + it("does not add header when no scopes are set", () => { + const config = runRequestInterceptors(IncidentCommander); + expect(config.headers["X-Flanksource-Scope"]).toBeUndefined(); + }); + + it("adds header with resolved payload when scopes are set", () => { + const scope = makeScope({ + id: "uuid-1", + targets: [{ config: { name: "my-cfg", agent: "a1" } }] + }); + setImpersonatedScopes([scope]); + + const config = runRequestInterceptors(IncidentCommander); + const header = JSON.parse(config.headers["X-Flanksource-Scope"] as string); + expect(header.config).toEqual([{ names: ["my-cfg"], agents: ["a1"] }]); + expect(header.scopes).toEqual(["uuid-1"]); + }); + + it("applies to multiple axios instances", () => { + setImpersonatedScopes([makeScope({ id: "uuid-1" })]); + + for (const instance of [IncidentCommander, Config, Catalog]) { + const config = runRequestInterceptors(instance); + const header = JSON.parse( + config.headers["X-Flanksource-Scope"] as string + ); + expect(header.scopes).toEqual(["uuid-1"]); + } + }); + + it("removes header after clearing scopes", () => { + setImpersonatedScopes([makeScope()]); + clearImpersonatedScopes(); + + const config = runRequestInterceptors(IncidentCommander); + expect(config.headers["X-Flanksource-Scope"]).toBeUndefined(); + }); +}); diff --git a/src/components/Scopes/Impersonation/__tests__/scopeImpersonationStore.unit.test.ts b/src/components/Scopes/Impersonation/__tests__/scopeImpersonationStore.unit.test.ts new file mode 100644 index 0000000000..b984d42282 --- /dev/null +++ b/src/components/Scopes/Impersonation/__tests__/scopeImpersonationStore.unit.test.ts @@ -0,0 +1,339 @@ +// ABOUTME: Tests for the scope impersonation sessionStorage helpers. +// ABOUTME: Validates read/write/clear, payload building, and custom event dispatching. + +import { ScopeDB, ScopeTargetForm } from "@flanksource-ui/api/types/scopes"; +import { + buildPayload, + buildPayloadFromTargets, + clearImpersonatedScopes, + getImpersonatedPayload, + getImpersonatedScopeIds, + getImpersonatedTargets, + getImpersonationMode, + hasImpersonatedScopes, + SCOPE_IMPERSONATION_CHANGE_EVENT, + setImpersonatedScopes, + setImpersonatedTargets +} from "../scopeImpersonationStore"; + +beforeEach(() => { + sessionStorage.clear(); +}); + +function makeScope(overrides: Partial = {}): ScopeDB { + return { + id: "scope-1", + name: "test", + targets: [], + source: "UI", + created_at: "", + updated_at: "", + ...overrides + }; +} + +describe("getImpersonatedScopeIds", () => { + it("returns empty array when nothing is stored", () => { + expect(getImpersonatedScopeIds()).toEqual([]); + }); + + it("returns stored IDs", () => { + sessionStorage.setItem( + "flanksource-impersonated-scope-ids", + JSON.stringify(["id-1", "id-2"]) + ); + expect(getImpersonatedScopeIds()).toEqual(["id-1", "id-2"]); + }); + + it("returns empty array for invalid JSON", () => { + sessionStorage.setItem("flanksource-impersonated-scope-ids", "bad"); + expect(getImpersonatedScopeIds()).toEqual([]); + }); +}); + +describe("setImpersonatedScopes", () => { + it("stores IDs and payload in sessionStorage", () => { + const scope = makeScope({ + id: "abc", + targets: [{ config: { name: "my-config", agent: "agent-1" } }] + }); + setImpersonatedScopes([scope]); + + expect( + JSON.parse(sessionStorage.getItem("flanksource-impersonated-scope-ids")!) + ).toEqual(["abc"]); + + const payload = JSON.parse( + sessionStorage.getItem("flanksource-impersonated-scope-payload")! + ); + expect(payload.config).toEqual([ + { names: ["my-config"], agents: ["agent-1"] } + ]); + expect(payload.scopes).toEqual(["abc"]); + }); + + it("sets mode to scopes", () => { + setImpersonatedScopes([makeScope()]); + expect(getImpersonationMode()).toBe("scopes"); + }); + + it("removes keys when given empty array", () => { + setImpersonatedScopes([makeScope()]); + setImpersonatedScopes([]); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-ids") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-payload") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-mode") + ).toBeNull(); + }); + + it("dispatches a change event", () => { + const handler = jest.fn(); + window.addEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + setImpersonatedScopes([makeScope()]); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + }); +}); + +describe("setImpersonatedTargets", () => { + it("stores payload and targets", () => { + const targets: ScopeTargetForm[] = [ + { config: { name: "cfg", tags: { env: "prod" } } } + ]; + setImpersonatedTargets(targets); + + expect(getImpersonationMode()).toBe("targets"); + expect(getImpersonatedTargets()).toEqual(targets); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-ids") + ).toBeNull(); + + const payload = getImpersonatedPayload(); + expect(payload?.config).toEqual([ + { names: ["cfg"], tags: { env: "prod" } } + ]); + }); + + it("removes keys when given empty array", () => { + setImpersonatedTargets([{ config: { name: "x" } }]); + setImpersonatedTargets([]); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-payload") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-targets") + ).toBeNull(); + }); + + it("dispatches a change event", () => { + const handler = jest.fn(); + window.addEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + setImpersonatedTargets([{ config: { name: "x" } }]); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + }); +}); + +describe("clearImpersonatedScopes", () => { + it("removes all keys from sessionStorage and dispatches event", () => { + setImpersonatedScopes([makeScope()]); + const handler = jest.fn(); + window.addEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + + clearImpersonatedScopes(); + + expect( + sessionStorage.getItem("flanksource-impersonated-scope-ids") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-payload") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-mode") + ).toBeNull(); + expect( + sessionStorage.getItem("flanksource-impersonated-scope-targets") + ).toBeNull(); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, handler); + }); +}); + +describe("hasImpersonatedScopes", () => { + it("returns false when empty", () => { + expect(hasImpersonatedScopes()).toBe(false); + }); + + it("returns true when scopes mode is active", () => { + setImpersonatedScopes([makeScope()]); + expect(hasImpersonatedScopes()).toBe(true); + }); + + it("returns true when targets mode is active", () => { + setImpersonatedTargets([{ config: { name: "x" } }]); + expect(hasImpersonatedScopes()).toBe(true); + }); +}); + +describe("getImpersonatedPayload", () => { + it("returns null when nothing stored", () => { + expect(getImpersonatedPayload()).toBeNull(); + }); + + it("returns stored payload from scopes mode", () => { + setImpersonatedScopes([ + makeScope({ + targets: [{ canary: { name: "c1" } }] + }) + ]); + const payload = getImpersonatedPayload(); + expect(payload?.canary).toEqual([{ names: ["c1"] }]); + }); + + it("returns stored payload from targets mode", () => { + setImpersonatedTargets([{ component: { name: "comp1" } }]); + const payload = getImpersonatedPayload(); + expect(payload?.component).toEqual([{ names: ["comp1"] }]); + }); +}); + +describe("buildPayload", () => { + it("maps config target", () => { + const payload = buildPayload([ + makeScope({ + targets: [ + { config: { name: "cfg", agent: "a1", tagSelector: "env=prod" } } + ] + }) + ]); + expect(payload.config).toEqual([ + { names: ["cfg"], agents: ["a1"], tags: { env: "prod" } } + ]); + }); + + it("maps component target", () => { + const payload = buildPayload([ + makeScope({ targets: [{ component: { name: "comp" } }] }) + ]); + expect(payload.component).toEqual([{ names: ["comp"] }]); + }); + + it("maps playbook target", () => { + const payload = buildPayload([ + makeScope({ targets: [{ playbook: { name: "pb" } }] }) + ]); + expect(payload.playbook).toEqual([{ names: ["pb"] }]); + }); + + it("maps canary target", () => { + const payload = buildPayload([ + makeScope({ targets: [{ canary: { name: "cn", agent: "a2" } }] }) + ]); + expect(payload.canary).toEqual([{ names: ["cn"], agents: ["a2"] }]); + }); + + it("maps view target", () => { + const payload = buildPayload([ + makeScope({ targets: [{ view: { name: "v1" } }] }) + ]); + expect(payload.view).toEqual([{ names: ["v1"] }]); + }); + + it("expands global to all resource types except playbook", () => { + const payload = buildPayload([ + makeScope({ targets: [{ global: { name: "*" } }] }) + ]); + expect(payload.config).toEqual([{ names: ["*"] }]); + expect(payload.component).toEqual([{ names: ["*"] }]); + expect(payload.canary).toEqual([{ names: ["*"] }]); + expect(payload.view).toEqual([{ names: ["*"] }]); + expect(payload.playbook).toBeUndefined(); + }); + + it("aggregates targets from multiple scopes", () => { + const payload = buildPayload([ + makeScope({ + id: "s1", + targets: [{ config: { name: "c1" } }] + }), + makeScope({ + id: "s2", + targets: [{ config: { name: "c2" } }] + }) + ]); + expect(payload.config).toEqual([{ names: ["c1"] }, { names: ["c2"] }]); + expect(payload.scopes).toEqual(["s1", "s2"]); + }); + + it("parses multi-tag selectors", () => { + const payload = buildPayload([ + makeScope({ + targets: [{ config: { name: "x", tagSelector: "env=prod,team=infra" } }] + }) + ]); + expect(payload.config![0].tags).toEqual({ env: "prod", team: "infra" }); + }); + + it("omits empty fields from RLSScope", () => { + const payload = buildPayload([ + makeScope({ targets: [{ config: { name: "x" } }] }) + ]); + const scope = payload.config![0]; + expect(scope.agents).toBeUndefined(); + expect(scope.tags).toBeUndefined(); + }); + + it("includes scope IDs", () => { + const payload = buildPayload([ + makeScope({ id: "uuid-1" }), + makeScope({ id: "uuid-2" }) + ]); + expect(payload.scopes).toEqual(["uuid-1", "uuid-2"]); + }); +}); + +describe("buildPayloadFromTargets", () => { + it("maps form targets with tags object", () => { + const payload = buildPayloadFromTargets([ + { config: { name: "cfg", agent: "a1", tags: { env: "prod" } } } + ]); + expect(payload.config).toEqual([ + { names: ["cfg"], agents: ["a1"], tags: { env: "prod" } } + ]); + }); + + it("handles wildcard targets", () => { + const payload = buildPayloadFromTargets([ + { config: { name: "*", wildcard: true, tags: { env: "prod" } } } + ]); + expect(payload.config).toEqual([{ names: ["*"] }]); + }); + + it("falls back to tagSelector string when no tags object", () => { + const payload = buildPayloadFromTargets([ + { config: { name: "x", tagSelector: "a=b" } } + ]); + expect(payload.config![0].tags).toEqual({ a: "b" }); + }); + + it("expands global to all types except playbook", () => { + const payload = buildPayloadFromTargets([ + { global: { name: "*", wildcard: true } } + ]); + expect(payload.config).toEqual([{ names: ["*"] }]); + expect(payload.component).toEqual([{ names: ["*"] }]); + expect(payload.canary).toEqual([{ names: ["*"] }]); + expect(payload.view).toEqual([{ names: ["*"] }]); + expect(payload.playbook).toBeUndefined(); + }); + + it("does not include scopes field", () => { + const payload = buildPayloadFromTargets([{ config: { name: "x" } }]); + expect(payload.scopes).toBeUndefined(); + }); +}); diff --git a/src/components/Scopes/Impersonation/scopeImpersonationStore.ts b/src/components/Scopes/Impersonation/scopeImpersonationStore.ts new file mode 100644 index 0000000000..bb8607613d --- /dev/null +++ b/src/components/Scopes/Impersonation/scopeImpersonationStore.ts @@ -0,0 +1,244 @@ +// ABOUTME: Manages impersonated scope selections in sessionStorage. +// ABOUTME: Provides read/write/clear helpers and a custom event for cross-component sync. + +import { + ScopeDB, + ScopeResourceSelector, + ScopeResourceSelectorForm, + ScopeTarget, + ScopeTargetForm +} from "@flanksource-ui/api/types/scopes"; + +export const SCOPE_IMPERSONATION_CHANGE_EVENT = "scope-impersonation-change"; + +const IDS_KEY = "flanksource-impersonated-scope-ids"; +const PAYLOAD_KEY = "flanksource-impersonated-scope-payload"; +const MODE_KEY = "flanksource-impersonated-scope-mode"; +const TARGETS_KEY = "flanksource-impersonated-scope-targets"; + +export type ImpersonationMode = "scopes" | "targets"; + +// Matches the backend rls.Scope struct +type RLSScope = { + tags?: Record; + agents?: string[]; + names?: string[]; + id?: string; +}; + +// Matches the backend rls.Payload struct +export type RLSPayload = { + config?: RLSScope[]; + component?: RLSScope[]; + playbook?: RLSScope[]; + canary?: RLSScope[]; + view?: RLSScope[]; + scopes?: string[]; +}; + +const RESOURCE_TYPES = [ + "config", + "component", + "playbook", + "canary", + "view" +] as const; + +type ResourceType = (typeof RESOURCE_TYPES)[number]; + +function parseTagSelector(tagSelector: string): Record { + const tags: Record = {}; + tagSelector.split(",").forEach((pair) => { + const [key, value] = pair.split("="); + if (key && value) { + tags[key.trim()] = value.trim(); + } + }); + return tags; +} + +function toRLSScope(selector: ScopeResourceSelector): RLSScope { + const scope: RLSScope = {}; + if (selector.name) scope.names = [selector.name]; + if (selector.agent) scope.agents = [selector.agent]; + if (selector.tagSelector) { + scope.tags = parseTagSelector(selector.tagSelector); + } + return scope; +} + +function addScopeToPayload( + payload: RLSPayload, + resourceType: ResourceType, + selector: ScopeResourceSelector +) { + const rlsScope = toRLSScope(selector); + if (!payload[resourceType]) { + payload[resourceType] = []; + } + payload[resourceType]!.push(rlsScope); +} + +export function buildPayload(scopes: ScopeDB[]): RLSPayload { + const payload: RLSPayload = {}; + + for (const scope of scopes) { + for (const target of scope.targets ?? []) { + for (const resourceType of RESOURCE_TYPES) { + const selector = target[resourceType as keyof ScopeTarget]; + if (selector) { + addScopeToPayload(payload, resourceType, selector); + } + } + + // "global" expands to all resource types except playbook + if (target.global) { + for (const resourceType of RESOURCE_TYPES) { + if (resourceType === "playbook") continue; + addScopeToPayload(payload, resourceType, target.global); + } + } + } + } + + const ids = scopes.map((s) => s.id); + if (ids.length > 0) { + payload.scopes = ids; + } + + return payload; +} + +function formSelectorToRLSScope(selector: ScopeResourceSelectorForm): RLSScope { + if (selector.wildcard) { + return { names: ["*"] }; + } + + const scope: RLSScope = {}; + if (selector.name) scope.names = [selector.name]; + if (selector.agent) scope.agents = [selector.agent]; + + // Form uses tags object; fall back to tagSelector string + if (selector.tags && Object.keys(selector.tags).length > 0) { + scope.tags = { ...selector.tags }; + } else if (selector.tagSelector) { + scope.tags = parseTagSelector(selector.tagSelector); + } + + return scope; +} + +function addFormScopeToPayload( + payload: RLSPayload, + resourceType: ResourceType, + selector: ScopeResourceSelectorForm +) { + const rlsScope = formSelectorToRLSScope(selector); + if (!payload[resourceType]) { + payload[resourceType] = []; + } + payload[resourceType]!.push(rlsScope); +} + +export function buildPayloadFromTargets( + targets: ScopeTargetForm[] +): RLSPayload { + const payload: RLSPayload = {}; + + for (const target of targets) { + for (const resourceType of RESOURCE_TYPES) { + const selector = target[resourceType as keyof ScopeTargetForm]; + if (selector) { + addFormScopeToPayload(payload, resourceType, selector); + } + } + + if (target.global) { + for (const resourceType of RESOURCE_TYPES) { + if (resourceType === "playbook") continue; + addFormScopeToPayload(payload, resourceType, target.global); + } + } + } + + return payload; +} + +export function getImpersonatedScopeIds(): string[] { + try { + const raw = sessionStorage.getItem(IDS_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +export function getImpersonatedPayload(): RLSPayload | null { + try { + const raw = sessionStorage.getItem(PAYLOAD_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +export function getImpersonationMode(): ImpersonationMode { + return (sessionStorage.getItem(MODE_KEY) as ImpersonationMode) || "scopes"; +} + +export function getImpersonatedTargets(): ScopeTargetForm[] { + try { + const raw = sessionStorage.getItem(TARGETS_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +export function setImpersonatedScopes(scopes: ScopeDB[]): void { + if (scopes.length === 0) { + sessionStorage.removeItem(IDS_KEY); + sessionStorage.removeItem(PAYLOAD_KEY); + sessionStorage.removeItem(MODE_KEY); + sessionStorage.removeItem(TARGETS_KEY); + } else { + const ids = scopes.map((s) => s.id); + const payload = buildPayload(scopes); + sessionStorage.setItem(IDS_KEY, JSON.stringify(ids)); + sessionStorage.setItem(PAYLOAD_KEY, JSON.stringify(payload)); + sessionStorage.setItem(MODE_KEY, "scopes"); + sessionStorage.removeItem(TARGETS_KEY); + } + window.dispatchEvent(new CustomEvent(SCOPE_IMPERSONATION_CHANGE_EVENT)); +} + +export function setImpersonatedTargets(targets: ScopeTargetForm[]): void { + if (targets.length === 0) { + sessionStorage.removeItem(IDS_KEY); + sessionStorage.removeItem(PAYLOAD_KEY); + sessionStorage.removeItem(MODE_KEY); + sessionStorage.removeItem(TARGETS_KEY); + } else { + const payload = buildPayloadFromTargets(targets); + sessionStorage.setItem(PAYLOAD_KEY, JSON.stringify(payload)); + sessionStorage.setItem(MODE_KEY, "targets"); + sessionStorage.setItem(TARGETS_KEY, JSON.stringify(targets)); + sessionStorage.removeItem(IDS_KEY); + } + window.dispatchEvent(new CustomEvent(SCOPE_IMPERSONATION_CHANGE_EVENT)); +} + +export function clearImpersonatedScopes(): void { + sessionStorage.removeItem(IDS_KEY); + sessionStorage.removeItem(PAYLOAD_KEY); + sessionStorage.removeItem(MODE_KEY); + sessionStorage.removeItem(TARGETS_KEY); + window.dispatchEvent(new CustomEvent(SCOPE_IMPERSONATION_CHANGE_EVENT)); +} + +export function hasImpersonatedScopes(): boolean { + return getImpersonatedPayload() !== null; +} diff --git a/src/components/Scopes/Impersonation/useImpersonatedScopes.ts b/src/components/Scopes/Impersonation/useImpersonatedScopes.ts new file mode 100644 index 0000000000..e249619cf8 --- /dev/null +++ b/src/components/Scopes/Impersonation/useImpersonatedScopes.ts @@ -0,0 +1,50 @@ +// ABOUTME: React hook that syncs impersonated scope state from sessionStorage. +// ABOUTME: Listens for custom events so all consumers stay in sync. + +import { ScopeDB, ScopeTargetForm } from "@flanksource-ui/api/types/scopes"; +import { useCallback, useSyncExternalStore } from "react"; +import { + clearImpersonatedScopes, + getImpersonatedScopeIds, + getImpersonatedTargets, + getImpersonationMode, + ImpersonationMode, + SCOPE_IMPERSONATION_CHANGE_EVENT, + setImpersonatedScopes, + setImpersonatedTargets +} from "./scopeImpersonationStore"; + +function subscribe(callback: () => void) { + window.addEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, callback); + return () => + window.removeEventListener(SCOPE_IMPERSONATION_CHANGE_EVENT, callback); +} + +function getSnapshot(): string { + return sessionStorage.getItem("flanksource-impersonated-scope-payload") ?? ""; +} + +function getServerSnapshot(): string { + return ""; +} + +export function useImpersonatedScopes() { + const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const active = raw !== ""; + const scopeIds = getImpersonatedScopeIds(); + const mode: ImpersonationMode = getImpersonationMode(); + const targets: ScopeTargetForm[] = getImpersonatedTargets(); + + const setScopes = useCallback( + (scopes: ScopeDB[]) => setImpersonatedScopes(scopes), + [] + ); + const setTargets = useCallback( + (t: ScopeTargetForm[]) => setImpersonatedTargets(t), + [] + ); + const clear = useCallback(() => clearImpersonatedScopes(), []); + + return { scopeIds, targets, mode, active, setScopes, setTargets, clear }; +} diff --git a/src/components/Users/UserProfile.tsx b/src/components/Users/UserProfile.tsx index d4b4ee5471..10649b0b6a 100644 --- a/src/components/Users/UserProfile.tsx +++ b/src/components/Users/UserProfile.tsx @@ -2,9 +2,13 @@ import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; import { lazy, Suspense, useState } from "react"; import { Search } from "lucide-react"; import { IoMdAirplane, IoMdDownload } from "react-icons/io"; +import { MdSecurity } from "react-icons/md"; +import { useFeatureFlagsContext } from "../../context/FeatureFlagsContext"; +import { hasImpersonatedScopes } from "../Scopes/Impersonation/scopeImpersonationStore"; import { KratosUserProfileDropdown } from "../Authentication/Kratos/KratosUserProfileDropdown"; import useDetermineAuthSystem from "../Authentication/useDetermineAuthSystem"; import AddKubeConfigModal from "../KubeConfig/AddKubeConfigModal"; +import ScopeImpersonationModal from "../Scopes/Impersonation/ScopeImpersonationModal"; import SetupMcpModal from "./SetupMcpModal"; const LazyResourceSelectorSearchModal = lazy(() => @@ -17,6 +21,10 @@ const LazyResourceSelectorSearchModal = lazy(() => export function UserProfileDropdown() { const authSystem = useDetermineAuthSystem(); + const { featureFlags } = useFeatureFlagsContext(); + const isRLSEnabled = featureFlags.some( + (f) => f.name === "rls.enable" && f.value === "true" + ); const [isDownloadKubeConfigModalOpen, setIsDownloadKubeConfigModalOpen] = useState(false); const [isMcpSetupModalOpen, setIsMcpSetupModalOpen] = useState(false); @@ -24,6 +32,8 @@ export function UserProfileDropdown() { isResourceSelectorSearchModalOpen, setIsResourceSelectorSearchModalOpen ] = useState(false); + const [isScopeImpersonationModalOpen, setIsScopeImpersonationModalOpen] = + useState(false); return ( <> @@ -52,6 +62,13 @@ export function UserProfileDropdown() { labelIcon={} onClick={() => setIsResourceSelectorSearchModalOpen(true)} /> + {isRLSEnabled && ( + } + onClick={() => setIsScopeImpersonationModalOpen(true)} + /> + )} @@ -62,6 +79,10 @@ export function UserProfileDropdown() { openResourceSelectorSearchModal={() => setIsResourceSelectorSearchModalOpen(true) } + openScopeImpersonationModal={() => + setIsScopeImpersonationModalOpen(true) + } + showScopeImpersonation={isRLSEnabled} /> )} )} + setIsScopeImpersonationModalOpen(false)} + /> ); } From c33f734f63f87db8c28f56d8cbe2521508b91923 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 1 Apr 2026 11:16:00 +0000 Subject: [PATCH 27/72] chore(release): 1.4.229 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7064e0dd8c..ffe2bfe108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.228", + "version": "1.4.229", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index b603720c59..197443c609 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.228", + "version": "1.4.229", "private": false, "files": [ "build", From 7e155ad2b1fb4326f23453763686821a027f204a Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 14:05:57 +0545 Subject: [PATCH 28/72] fix: show error in the invitation modal The toast only shows error happened with no details --- src/pages/UsersPage.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 4630147f00..eabf310c09 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -11,6 +11,7 @@ import { FaCopy } from "react-icons/fa"; import { ImUserPlus } from "react-icons/im"; import { getRegisteredUsers } from "../api/services/users"; import { Button, Modal } from "../components"; +import { ErrorViewer } from "../components/ErrorViewer"; import { AuthorizationAccessCheck } from "../components/Permissions/AuthorizationAccessCheck"; import { toastError, toastSuccess } from "../components/Toast/toast"; import { UserList } from "../components/Users/UserList"; @@ -42,14 +43,17 @@ export function UsersPage() { staleTime: 0 }); - const { mutate: inviteUserFunction, isLoading: isInviting } = useInviteUser({ + const { + mutate: inviteUserFunction, + isLoading: isInviting, + error: inviteError, + reset: resetInviteError + } = useInviteUser({ onSuccess: (data) => { refetch(); + resetInviteError(); setIsOpen(false); setInviteLink(data.link); - }, - onError: (error) => { - toastError(error.message); } }); @@ -109,7 +113,10 @@ export function UsersPage() { > - + +
+ + {title} + + + {description} + + {error ? ( +
+ +
+ ) : null} +
- - - + + + + + + + + ); } From ba7df4c3fafaad6e58759d249b5cf3a85bf246cc Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 00:32:53 +0545 Subject: [PATCH 37/72] fix(ui): refine flat tabs header spacing and typography --- src/ui/Tabs/FlatTabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/Tabs/FlatTabs.tsx b/src/ui/Tabs/FlatTabs.tsx index 52f8daea57..639664cf1e 100644 --- a/src/ui/Tabs/FlatTabs.tsx +++ b/src/ui/Tabs/FlatTabs.tsx @@ -36,7 +36,7 @@ export default function FlatTabs({
-
- {tabs.find((tab) => tab.current)?.content} +
+ {tabs.find((tab) => tab.current)?.content} +
); From 8ad5bb13160fcfcd2ee952cf54306bcc9905d4fb Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 00:48:48 +0545 Subject: [PATCH 39/72] feat(playbooks): improve permissions modal tabs and table layout --- .../Permissions/PermissionsView.tsx | 24 ++++++++++--------- .../Settings/PlaybookPermissionsModal.tsx | 5 ++-- src/ui/Tabs/FlatTabs.tsx | 4 ++-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index 0136685154..caa931393b 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -140,9 +140,9 @@ export default function PermissionsView({ const permissions = data?.data || []; return ( - <> +
{showAddPermission && ( -
+
)} - setSelectedPermission(row)} - hideResourceColumn={hideResourceColumn} - /> +
+ setSelectedPermission(row)} + hideResourceColumn={hideResourceColumn} + /> +
{selectedPermission && ( )} - +
); } diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx index 5db1614e37..bc932b66cc 100644 --- a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -31,13 +31,14 @@ export default function PlaybookPermissionsModal({ onClose={onClose} open={isOpen} size="full" - containerClassName="h-full overflow-auto" - bodyClass="flex w-full flex-1 flex-col overflow-y-auto" + containerClassName="h-full overflow-hidden" + bodyClass="flex w-full min-h-0 flex-1 flex-col" helpLink="playbooks" > setActiveTab(label)} + contentClassName="px-4 pb-4" tabs={[ { label: "Inbound", diff --git a/src/ui/Tabs/FlatTabs.tsx b/src/ui/Tabs/FlatTabs.tsx index e926fc02bd..c0dd74c37f 100644 --- a/src/ui/Tabs/FlatTabs.tsx +++ b/src/ui/Tabs/FlatTabs.tsx @@ -36,7 +36,7 @@ export default function FlatTabs({ ))}
-
+
-
+
{tabs.find((tab) => tab.current)?.content}
From e19784f089d210bc7e4ff25cce68f9c67b838eef Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 00:55:13 +0545 Subject: [PATCH 40/72] fix: playbook card padding --- src/components/Playbooks/Runs/ApprovePlaybookRunModal.tsx | 2 +- src/components/Playbooks/Runs/CancelPlaybookRunModal.tsx | 2 +- src/components/Playbooks/Settings/PlaybookSpecsList.tsx | 4 ++-- src/pages/playbooks/PlaybooksList.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Playbooks/Runs/ApprovePlaybookRunModal.tsx b/src/components/Playbooks/Runs/ApprovePlaybookRunModal.tsx index a31a3112ac..016b8df628 100644 --- a/src/components/Playbooks/Runs/ApprovePlaybookRunModal.tsx +++ b/src/components/Playbooks/Runs/ApprovePlaybookRunModal.tsx @@ -47,9 +47,9 @@ export default function ApprovePlaybookRunModal({ title={`Approve Playbook ${playbookTitle} Run`} description={

Are you sure you want to approve this playbook run?

} onConfirm={() => approve(playbookRunId)} - open={open} onClose={handleClose} isOpen={open} + isLoading={isLoading} yesLabel={isLoading ? "Approving..." : "Approve"} closeLabel="Cancel" error={error} diff --git a/src/components/Playbooks/Runs/CancelPlaybookRunModal.tsx b/src/components/Playbooks/Runs/CancelPlaybookRunModal.tsx index b2117b5ffc..5504a93876 100644 --- a/src/components/Playbooks/Runs/CancelPlaybookRunModal.tsx +++ b/src/components/Playbooks/Runs/CancelPlaybookRunModal.tsx @@ -37,9 +37,9 @@ export default function CancelPlaybookRunModal({ title={`Cancel run`} description={

Are you sure you want to cancel this run?

} onConfirm={() => cancel(playbookRunId)} - open={open} onClose={onClose} isOpen={open} + isLoading={isLoading} yesLabel={isLoading ? "Cancelling..." : "Cancel"} closeLabel="Close" /> diff --git a/src/components/Playbooks/Settings/PlaybookSpecsList.tsx b/src/components/Playbooks/Settings/PlaybookSpecsList.tsx index d81f5f721d..c2281cd7e7 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecsList.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecsList.tsx @@ -35,9 +35,9 @@ export default function PlaybookSpecsList({ {Object.keys(groupPlaybooksByType).map((type) => { const playbooks = groupPlaybooksByType[type]; return ( -
+

{type ?? "Unknown"}

-
+
{playbooks.map((playbook) => (
-
+
{error && !playbooks ? ( ) : ( From a0f8beeb981ab2a7fa31dd6a6398b4c2b48204ea Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 11:47:31 +0545 Subject: [PATCH 41/72] fix(permissions): surface fetch errors in permissions view --- src/components/Permissions/PermissionsView.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index caa931393b..4b552b8e08 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -9,6 +9,8 @@ import { import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactTableSortState"; import { useQuery } from "@tanstack/react-query"; +import { getErrorMessage } from "@flanksource-ui/api/types/error"; +import { toastError } from "@flanksource-ui/components/Toast/toast"; import { useEffect, useState } from "react"; import { Button } from ".."; import { FormikSelectDropdownOption } from "../Forms/Formik/FormikSelectDropdown"; @@ -104,7 +106,7 @@ export default function PermissionsView({ ? "created_by" : sortState[0]?.id; - const { isLoading, data, refetch } = useQuery({ + const { isLoading, data, refetch, isError, error } = useQuery({ queryKey: [ "permissions_summary", permissionRequest, @@ -129,6 +131,12 @@ export default function PermissionsView({ setIsLoading(isLoading); }, [isLoading, setIsLoading]); + useEffect(() => { + if (isError) { + toastError(`Failed to fetch permissions: ${getErrorMessage(error)}`); + } + }, [error, isError]); + useEffect(() => { if (onRefetch) { onRefetch(refetch); From a94f8f66e70b1b2ee7209186e266f49d2f480498 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 2 Apr 2026 13:44:40 +0545 Subject: [PATCH 42/72] fix(permissions): refresh playbook permission tabs on open and switch --- src/api/services/permissions.ts | 25 +++++++- .../Permissions/PermissionsTable.tsx | 4 +- .../Settings/PlaybookPermissionsModal.tsx | 63 ++++++++++++++++--- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 61c75b9bd0..d6d3e9d6d8 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -12,10 +12,13 @@ export type FetchPermissionsInput = { checkId?: string; canaryId?: string; playbookId?: string; + playbookName?: string; + playbookNamespace?: string; connectionId?: string; subject?: string; action?: string; subject_type?: "playbook" | "team" | "person" | "notification" | "component"; + direction?: "inbound" | "outbound"; }; function composeQueryParamForFetchPermissions({ @@ -51,8 +54,9 @@ function composeQueryParamForFetchPermissions({ if (canaryId) { filters.push(`canary_id=eq.${canaryId}`); } - if (playbookId) { - filters.push(`playbook_id=eq.${playbookId}`); + if (playbookId && !subject) { + filters.push(`subject=eq.${playbookId}`); + filters.push(`subject_type=eq.playbook`); } if (connectionId) { filters.push(`connection_id=eq.${connectionId}`); @@ -90,7 +94,22 @@ export function fetchPermissions( const { pageSize, pageIndex, sortBy, sortOrder } = options; const sortParams = sortBy ? `&order=${sortBy}.${sortOrder ?? "asc"}` : ""; - const url = `/permissions_summary?${queryParam}&select=${selectFields}&deleted_at=is.null&limit=${pageSize}&offset=${pageIndex * pageSize}${sortParams}`; + const isInboundPlaybookSelectorQuery = + input.direction === "inbound" && !!input.playbookName; + + const url = isInboundPlaybookSelectorQuery + ? (() => { + const params = new URLSearchParams({ + p_field: "playbooks", + p_name: input.playbookName! + }); + if (input.playbookNamespace) { + params.set("p_namespace", input.playbookNamespace); + } + return `/rpc/permissions_for_obj_selector?${params.toString()}&select=${selectFields}&limit=${pageSize}&offset=${pageIndex * pageSize}${sortParams}`; + })() + : `/permissions_summary?${queryParam}&select=${selectFields}&deleted_at=is.null&limit=${pageSize}&offset=${pageIndex * pageSize}${sortParams}`; + return resolvePostGrestRequestWithPagination( IncidentCommander.get(url, { headers: { diff --git a/src/components/Permissions/PermissionsTable.tsx b/src/components/Permissions/PermissionsTable.tsx index af07e67eb8..54f5206045 100644 --- a/src/components/Permissions/PermissionsTable.tsx +++ b/src/components/Permissions/PermissionsTable.tsx @@ -249,7 +249,7 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "action", accessorFn: (row) => row.action, header: "Action", - size: 60, + size: 70, Cell: ({ row }) => { const action = row.original.action; const deny = row.original.deny; @@ -301,7 +301,7 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "created_by", accessorFn: (row) => row.created_by, header: "Created By", - size: 40, + size: 50, Cell: ({ row }) => { const createdBy = row.original.created_by; const source = row.original.source; diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx index bc932b66cc..b8f59e732b 100644 --- a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -2,7 +2,8 @@ import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; import { Modal } from "@flanksource-ui/ui/Modal"; import FlatTabs from "@flanksource-ui/ui/Tabs/FlatTabs"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import PlaybookSpecModalTitle from "../PlaybookSpecModalTitle"; type PlaybookPermissionsModalProps = { @@ -19,6 +20,44 @@ export default function PlaybookPermissionsModal({ const [activeTab, setActiveTab] = useState<"who-can-run" | "what-it-can-do">( "who-can-run" ); + const queryClient = useQueryClient(); + + const inboundPermissionRequest = useMemo( + () => ({ + direction: "inbound" as const, + playbookId: playbook.id, + playbookName: playbook.name, + playbookNamespace: playbook.namespace + }), + [playbook.id, playbook.name, playbook.namespace] + ); + + const outboundPermissionRequest = useMemo( + () => ({ + direction: "outbound" as const, + subject: playbook.id, + subject_type: "playbook" as const + }), + [playbook.id] + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + queryClient.invalidateQueries({ + queryKey: ["permissions_summary", inboundPermissionRequest] + }); + queryClient.invalidateQueries({ + queryKey: ["permissions_summary", outboundPermissionRequest] + }); + }, [ + inboundPermissionRequest, + isOpen, + outboundPermissionRequest, + queryClient + ]); return ( setActiveTab(label)} + setActiveTab={(label) => { + setActiveTab(label); + + const permissionRequest = + label === "who-can-run" + ? inboundPermissionRequest + : outboundPermissionRequest; + + queryClient.invalidateQueries({ + queryKey: ["permissions_summary", permissionRequest] + }); + }} contentClassName="px-4 pb-4" tabs={[ { @@ -47,9 +97,7 @@ export default function PlaybookPermissionsModal({ content: ( Date: Fri, 3 Apr 2026 12:02:32 +0545 Subject: [PATCH 43/72] fix(permissions): correct playbook modal column visibility --- src/components/Permissions/PermissionsTable.tsx | 11 ++++++++++- src/components/Permissions/PermissionsView.tsx | 3 +++ .../Playbooks/Settings/PlaybookPermissionsModal.tsx | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/Permissions/PermissionsTable.tsx b/src/components/Permissions/PermissionsTable.tsx index 54f5206045..68549aaa48 100644 --- a/src/components/Permissions/PermissionsTable.tsx +++ b/src/components/Permissions/PermissionsTable.tsx @@ -327,6 +327,7 @@ type PermissionsTableProps = { totalEntries: number; handleRowClick?: (row: PermissionsSummary) => void; hideResourceColumn?: boolean; + hideSubjectColumn?: boolean; }; export default function PermissionsTable({ @@ -335,10 +336,18 @@ export default function PermissionsTable({ pageCount, totalEntries, hideResourceColumn = false, + hideSubjectColumn = false, handleRowClick = () => {} }: PermissionsTableProps) { + const hiddenColumns = [ + ...(hideResourceColumn ? ["Resource"] : []), + ...(hideSubjectColumn ? ["subject"] : []) + ]; + const tableKey = hiddenColumns.join("|") || "none"; + return ( ); } diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index 4b552b8e08..d552e84a7f 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -78,6 +78,7 @@ type PermissionsViewProps = { permissionRequest: FetchPermissionsInput; setIsLoading?: (isLoading: boolean) => void; hideResourceColumn?: boolean; + hideSubjectColumn?: boolean; newPermissionData?: Partial; showAddPermission?: boolean; onRefetch?: (refetch: () => void) => void; @@ -87,6 +88,7 @@ export default function PermissionsView({ permissionRequest, setIsLoading = () => {}, hideResourceColumn = false, + hideSubjectColumn = false, newPermissionData, showAddPermission = false, onRefetch @@ -168,6 +170,7 @@ export default function PermissionsView({ totalEntries={totalEntries} handleRowClick={(row) => setSelectedPermission(row)} hideResourceColumn={hideResourceColumn} + hideSubjectColumn={hideSubjectColumn} />
{selectedPermission && ( diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx index b8f59e732b..14af3d5d24 100644 --- a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -111,7 +111,7 @@ export default function PlaybookPermissionsModal({ current: activeTab === "what-it-can-do", content: ( Date: Fri, 3 Apr 2026 12:05:06 +0545 Subject: [PATCH 44/72] refactor: PermissionResourceCell --- .../Permissions/PermissionResourceCell.tsx | 259 ++++++++++++++++++ .../Permissions/PermissionsTable.tsx | 149 +--------- 2 files changed, 261 insertions(+), 147 deletions(-) create mode 100644 src/components/Permissions/PermissionResourceCell.tsx diff --git a/src/components/Permissions/PermissionResourceCell.tsx b/src/components/Permissions/PermissionResourceCell.tsx new file mode 100644 index 0000000000..aca52b0985 --- /dev/null +++ b/src/components/Permissions/PermissionResourceCell.tsx @@ -0,0 +1,259 @@ +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import CanaryLink from "../Canary/CanaryLink"; +import ConfigLink from "../Configs/ConfigLink/ConfigLink"; +import ConnectionIcon from "../Connections/ConnectionIcon"; +import PlaybookSpecIcon from "../Playbooks/Settings/PlaybookSpecIcon"; +import { TopologyLink } from "../Topology/TopologyLink"; +import { permissionObjectList } from "./ManagePermissions/Forms/FormikPermissionSelectResourceFields"; +import { PermissionErrorDisplay } from "./PermissionErrorDisplay"; + +interface ScopeObject { + namespace?: string; + name?: string; +} + +const formatScopeText = (scope: ScopeObject): string => { + const namespace = scope.namespace || ""; + const name = scope.name || ""; + return namespace && name ? `${namespace}/${name}` : name; +}; + +type SelectorResourceType = + | "playbooks" + | "connections" + | "configs" + | "components"; + +type ResourceSelector = { + id?: string; + name?: string; + namespace?: string; + type?: string; + icon?: string; +}; + +type ResourceSelectorWithName = ResourceSelector & { + name: string; +}; + +const getSingleResourceSelector = ( + objectSelector?: PermissionsSummary["object_selector"] +): + | { resourceType: SelectorResourceType; selector: ResourceSelectorWithName } + | undefined => { + if (!objectSelector) { + return undefined; + } + + const selectorEntries = Object.entries(objectSelector).filter( + ([, value]) => Array.isArray(value) && value.length > 0 + ); + + if (selectorEntries.length !== 1) { + return undefined; + } + + const [resourceType, selectorItems] = selectorEntries[0] as [ + string, + ResourceSelector[] + ]; + + if ( + !["playbooks", "connections", "configs", "components"].includes( + resourceType + ) || + selectorItems.length !== 1 + ) { + return undefined; + } + + const selector = selectorItems[0]; + + if (!selector?.name) { + return undefined; + } + + return { + resourceType: resourceType as SelectorResourceType, + selector: { + ...selector, + name: selector.name + } + }; +}; + +type PermissionResourceCellProps = { + permission: PermissionsSummary; +}; + +export default function PermissionResourceCell({ + permission +}: PermissionResourceCellProps) { + const config = permission.config_object; + const playbook = permission.playbook_object; + const component = permission.component_object; + const connection = permission.connection_object; + const canary = permission.canary_object; + const object = permission.object; + const objectSelector = permission.object_selector; + const error = permission.error; + + if (objectSelector) { + // Format scopes as "Scope: namespace/name, namespace2/name2" + if (objectSelector.scopes && Array.isArray(objectSelector.scopes)) { + const scopes = objectSelector.scopes; + const maxDisplay = 2; + const displayScopes = scopes.slice(0, maxDisplay); + const remaining = scopes.length - maxDisplay; + + const scopeText = displayScopes + .map(formatScopeText) + .filter(Boolean) + .join(", "); + + const fullScopeText = scopes + .map(formatScopeText) + .filter(Boolean) + .join(", "); + + return ( +
+
+ Scope: + + {scopeText} + {remaining > 0 && ` and ${remaining} more...`} + +
+ +
+ ); + } + + const selectedResource = getSingleResourceSelector(objectSelector); + + if (selectedResource) { + const selectorLabel = selectedResource.selector.namespace + ? `${selectedResource.selector.namespace}/${selectedResource.selector.name}` + : selectedResource.selector.name; + + if (selectedResource.resourceType === "connections") { + return ( +
+
+ Connection: + +
+ +
+ ); + } + + return ( +
+
+ + {selectedResource.resourceType === "playbooks" + ? "Playbook:" + : selectedResource.resourceType === "configs" + ? "Catalog:" + : "Component:"} + + {selectorLabel} +
+ +
+ ); + } + + // Fallback to JSON for non-scope object selectors + return ( +
+
+ + {JSON.stringify(objectSelector)} + +
+ +
+ ); + } + + if (object) { + return ( +
+
+ + {permissionObjectList.find((o) => o.value === object)?.label} + +
+ +
+ ); + } + + return ( +
+
+
+ {config && ( +
+ Catalog: + +
+ )} + + {playbook && ( +
+ Playbook: + +
+ )} + + {component && ( +
+ Component: + +
+ )} + + {canary && ( +
+ Canary: + +
+ )} + + {connection && ( +
+ Connection: + +
+ )} +
+
+ +
+ ); +} diff --git a/src/components/Permissions/PermissionsTable.tsx b/src/components/Permissions/PermissionsTable.tsx index 68549aaa48..132abb61f7 100644 --- a/src/components/Permissions/PermissionsTable.tsx +++ b/src/components/Permissions/PermissionsTable.tsx @@ -4,29 +4,13 @@ import { Icon } from "@flanksource-ui/ui/Icons/Icon"; import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { MRT_ColumnDef } from "mantine-react-table"; -import CanaryLink from "../Canary/CanaryLink"; -import ConfigLink from "../Configs/ConfigLink/ConfigLink"; -import ConnectionIcon from "../Connections/ConnectionIcon"; import PlaybookSpecIcon from "../Playbooks/Settings/PlaybookSpecIcon"; -import { TopologyLink } from "../Topology/TopologyLink"; -import { permissionObjectList } from "./ManagePermissions/Forms/FormikPermissionSelectResourceFields"; import { permissionsActionsList } from "./PermissionsView"; import { BsBan } from "react-icons/bs"; import { Link } from "react-router-dom"; import CRDSource from "../Settings/CRDSource"; -import { PermissionErrorDisplay } from "./PermissionErrorDisplay"; import FilterByCellValue from "@flanksource-ui/ui/DataTable/FilterByCellValue"; - -interface ScopeObject { - namespace?: string; - name?: string; -} - -const formatScopeText = (scope: ScopeObject): string => { - const namespace = scope.namespace || ""; - const name = scope.name || ""; - return namespace && name ? `${namespace}/${name}` : name; -}; +import PermissionResourceCell from "./PermissionResourceCell"; const permissionsTableColumns: MRT_ColumnDef[] = [ { @@ -113,136 +97,7 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ enableHiding: true, enableSorting: false, size: 150, - Cell: ({ row }) => { - const config = row.original.config_object; - const playbook = row.original.playbook_object; - const component = row.original.component_object; - const connection = row.original.connection_object; - const canary = row.original.canary_object; - const object = row.original.object; - const objectSelector = row.original.object_selector; - const error = row.original.error; - - if (objectSelector) { - // Format scopes as "Scope: namespace/name, namespace2/name2" - if (objectSelector.scopes && Array.isArray(objectSelector.scopes)) { - const scopes = objectSelector.scopes; - const maxDisplay = 2; - const displayScopes = scopes.slice(0, maxDisplay); - const remaining = scopes.length - maxDisplay; - - const scopeText = displayScopes - .map(formatScopeText) - .filter(Boolean) - .join(", "); - - const fullScopeText = scopes - .map(formatScopeText) - .filter(Boolean) - .join(", "); - - return ( -
-
- Scope: - - {scopeText} - {remaining > 0 && ` and ${remaining} more...`} - -
- -
- ); - } - - // Fallback to JSON for non-scope object selectors - return ( -
-
- - {JSON.stringify(objectSelector)} - -
- -
- ); - } - - if (object) { - return ( -
-
- - {permissionObjectList.find((o) => o.value === object)?.label} - -
- -
- ); - } - - return ( -
-
-
- {config && ( -
- Catalog: - -
- )} - - {playbook && ( -
- Playbook: - -
- )} - - {component && ( -
- Component: - -
- )} - - {canary && ( -
- Canary: - -
- )} - - {connection && ( -
- Connection: - -
- )} -
-
- -
- ); - } + Cell: ({ row }) => }, { From f345da15ca8292d23be00c2048f507734da8bf90 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 3 Apr 2026 13:57:02 +0545 Subject: [PATCH 45/72] feat(permissions): render selector connections with link resolution --- src/api/services/connections.ts | 17 +++++++ src/components/Connections/ConnectionLink.tsx | 51 ++++++++++++++++--- .../Permissions/PermissionResourceCell.tsx | 11 ++-- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/api/services/connections.ts b/src/api/services/connections.ts index a685b24186..cdb310568b 100644 --- a/src/api/services/connections.ts +++ b/src/api/services/connections.ts @@ -15,3 +15,20 @@ export async function getConnectionByID(id: string) { >(`/connections?id=eq.${id}&select=id,name,type`); return res?.data?.[0] ?? null; } + +export async function getConnectionByNamespaceName( + name: string, + namespace?: string +) { + const filters = [`name=eq.${name}`]; + + if (namespace) { + filters.push(`namespace=eq.${namespace}`); + } + + const res = await IncidentCommander.get< + Pick[] + >(`/connections?${filters.join("&")}&select=id,name,type`); + + return res?.data?.[0] ?? null; +} diff --git a/src/components/Connections/ConnectionLink.tsx b/src/components/Connections/ConnectionLink.tsx index 06985cb830..09e85b8a56 100644 --- a/src/components/Connections/ConnectionLink.tsx +++ b/src/components/Connections/ConnectionLink.tsx @@ -1,22 +1,48 @@ -import { getConnectionByID } from "@flanksource-ui/api/services/connections"; +import { + getConnectionByID, + getConnectionByNamespaceName +} from "@flanksource-ui/api/services/connections"; import TextSkeletonLoader from "@flanksource-ui/ui/SkeletonLoader/TextSkeletonLoader"; import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; import { Connection } from "./ConnectionFormModal"; import ConnectionIcon from "./ConnectionIcon"; type ConnectionLinkProps = { connection?: Pick; - connectionId: string; + connectionId?: string; + connectionName?: string; + connectionNamespace?: string; }; export default function ConnectionLink({ connection, - connectionId + connectionId, + connectionName, + connectionNamespace }: ConnectionLinkProps) { const { isLoading, data } = useQuery({ - queryKey: ["connections", connectionId], - queryFn: () => getConnectionByID(connectionId), - enabled: connection === undefined && !!connectionId + queryKey: [ + "connections", + connectionId, + connectionName, + connectionNamespace + ], + queryFn: () => { + if (connectionId) { + return getConnectionByID(connectionId); + } + + if (connectionName) { + return getConnectionByNamespaceName( + connectionName, + connectionNamespace + ); + } + + return Promise.resolve(null); + }, + enabled: connection === undefined && (!!connectionId || !!connectionName) }); if (isLoading) { @@ -29,5 +55,16 @@ export default function ConnectionLink({ return null; } - return ; + if (!connectionData.id) { + return ; + } + + return ( + + + + ); } diff --git a/src/components/Permissions/PermissionResourceCell.tsx b/src/components/Permissions/PermissionResourceCell.tsx index aca52b0985..c0190c0f40 100644 --- a/src/components/Permissions/PermissionResourceCell.tsx +++ b/src/components/Permissions/PermissionResourceCell.tsx @@ -2,6 +2,7 @@ import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; import CanaryLink from "../Canary/CanaryLink"; import ConfigLink from "../Configs/ConfigLink/ConfigLink"; import ConnectionIcon from "../Connections/ConnectionIcon"; +import ConnectionLink from "../Connections/ConnectionLink"; import PlaybookSpecIcon from "../Playbooks/Settings/PlaybookSpecIcon"; import { TopologyLink } from "../Topology/TopologyLink"; import { permissionObjectList } from "./ManagePermissions/Forms/FormikPermissionSelectResourceFields"; @@ -142,12 +143,10 @@ export default function PermissionResourceCell({
Connection: -
From a3c4833e9172bf3cea21d2e66c95c0e6d25b12d9 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 3 Apr 2026 14:32:28 +0545 Subject: [PATCH 46/72] fix permission review comments for resource rendering and confirm loading --- src/api/services/permissions.ts | 5 +- .../Permissions/PermissionResourceCell.tsx | 131 ++++++++++-------- .../AlertDialog/ConfirmationPromptDialog.tsx | 11 +- 3 files changed, 87 insertions(+), 60 deletions(-) diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index d6d3e9d6d8..49acc18283 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -54,9 +54,8 @@ function composeQueryParamForFetchPermissions({ if (canaryId) { filters.push(`canary_id=eq.${canaryId}`); } - if (playbookId && !subject) { - filters.push(`subject=eq.${playbookId}`); - filters.push(`subject_type=eq.playbook`); + if (playbookId) { + filters.push(`playbook_id=eq.${playbookId}`); } if (connectionId) { filters.push(`connection_id=eq.${connectionId}`); diff --git a/src/components/Permissions/PermissionResourceCell.tsx b/src/components/Permissions/PermissionResourceCell.tsx index c0190c0f40..d55948ba54 100644 --- a/src/components/Permissions/PermissionResourceCell.tsx +++ b/src/components/Permissions/PermissionResourceCell.tsx @@ -1,11 +1,13 @@ -import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { + PermissionGlobalObject, + PermissionsSummary +} from "@flanksource-ui/api/types/permissions"; import CanaryLink from "../Canary/CanaryLink"; import ConfigLink from "../Configs/ConfigLink/ConfigLink"; import ConnectionIcon from "../Connections/ConnectionIcon"; import ConnectionLink from "../Connections/ConnectionLink"; import PlaybookSpecIcon from "../Playbooks/Settings/PlaybookSpecIcon"; import { TopologyLink } from "../Topology/TopologyLink"; -import { permissionObjectList } from "./ManagePermissions/Forms/FormikPermissionSelectResourceFields"; import { PermissionErrorDisplay } from "./PermissionErrorDisplay"; interface ScopeObject { @@ -87,6 +89,16 @@ type PermissionResourceCellProps = { permission: PermissionsSummary; }; +const OBJECT_LABELS: Record = { + catalog: "Catalog", + component: "Component", + canaries: "Canaries", + connection: "Connection", + playbook: "Playbook", + topology: "Topology", + mcp: "MCP" +}; + export default function PermissionResourceCell({ permission }: PermissionResourceCellProps) { @@ -98,6 +110,9 @@ export default function PermissionResourceCell({ const object = permission.object; const objectSelector = permission.object_selector; const error = permission.error; + const hasConcreteObject = Boolean( + config || playbook || component || canary || connection + ); if (objectSelector) { // Format scopes as "Scope: namespace/name, namespace2/name2" @@ -187,13 +202,69 @@ export default function PermissionResourceCell({ ); } + if (hasConcreteObject) { + return ( +
+
+
+ {config && ( +
+ Catalog: + +
+ )} + + {playbook && ( +
+ Playbook: + +
+ )} + + {component && ( +
+ Component: + +
+ )} + + {canary && ( +
+ Canary: + +
+ )} + + {connection && ( +
+ Connection: + +
+ )} +
+
+ +
+ ); + } + if (object) { return (
- - {permissionObjectList.find((o) => o.value === object)?.label} - + {OBJECT_LABELS[object] ?? object}
@@ -202,56 +273,6 @@ export default function PermissionResourceCell({ return (
-
-
- {config && ( -
- Catalog: - -
- )} - - {playbook && ( -
- Playbook: - -
- )} - - {component && ( -
- Component: - -
- )} - - {canary && ( -
- Canary: - -
- )} - - {connection && ( -
- Connection: - -
- )} -
-
); diff --git a/src/ui/AlertDialog/ConfirmationPromptDialog.tsx b/src/ui/AlertDialog/ConfirmationPromptDialog.tsx index 4794028187..2a2183cd92 100644 --- a/src/ui/AlertDialog/ConfirmationPromptDialog.tsx +++ b/src/ui/AlertDialog/ConfirmationPromptDialog.tsx @@ -109,14 +109,21 @@ export function ConfirmationPromptDialog({ - + ) : null} + +
+ + ); + }} + + ); +} + +export default function PermissionAccessCheckModal({ + open, + onOpenChange, + config +}: PermissionAccessCheckModalProps) { + return ( + + + + {config.title ?? "Permission Access Check"} + + {config.description ?? + "Select a subject and action to check access for this resource."} + + + + onOpenChange(false)} + /> + + + ); +} diff --git a/src/components/Permissions/PermissionSubjectPanel.tsx b/src/components/Permissions/PermissionSubjectPanel.tsx new file mode 100644 index 0000000000..fdf21580a6 --- /dev/null +++ b/src/components/Permissions/PermissionSubjectPanel.tsx @@ -0,0 +1,72 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Input } from "@flanksource-ui/components/ui/input"; + +export type PermissionSubjectGroup = { + type: PermissionSubject["type"]; + list: PermissionSubject[]; +}; + +type PermissionSubjectPanelProps = { + subjectSearch: string; + onSubjectSearchChange: (value: string) => void; + groupedSubjects: PermissionSubjectGroup[]; + selectedSubjectId: string | null; + onSelectSubject: (subjectId: string) => void; +}; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +export default function PermissionSubjectPanel({ + subjectSearch, + onSubjectSearchChange, + groupedSubjects, + selectedSubjectId, + onSelectSubject +}: PermissionSubjectPanelProps) { + return ( +
+ onSubjectSearchChange(event.target.value)} + /> + +
+ {groupedSubjects.map((group) => ( +
+
+ {TYPE_LABELS[group.type] ?? group.type} +
+ + {group.list.map((subject) => { + const isActive = subject.id === selectedSubjectId; + + return ( + + ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/components/Permissions/PermissionsTabsLinks.tsx b/src/components/Permissions/PermissionsTabsLinks.tsx new file mode 100644 index 0000000000..d74f54c387 --- /dev/null +++ b/src/components/Permissions/PermissionsTabsLinks.tsx @@ -0,0 +1,91 @@ +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; +import TabbedLinks from "@flanksource-ui/ui/Tabs/TabbedLinks"; +import clsx from "clsx"; +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import ConfigSidebar from "../Configs/Sidebar/ConfigSidebar"; +import { ErrorBoundary } from "../ErrorBoundary"; + +type PermissionsTabsLinksProps = { + activeTab: "Permissions" | "Subjects"; + children: React.ReactNode; + className?: string; + onRefresh?: () => void; + loading?: boolean; + headerAction?: React.ReactNode; +}; + +export default function PermissionsTabsLinks({ + activeTab, + children, + className, + onRefresh = () => {}, + loading = false, + headerAction +}: PermissionsTabsLinksProps) { + const [searchParams] = useSearchParams(); + + const tabLinks = useMemo(() => { + const query = searchParams.toString(); + const search = query ? `?${query}` : ""; + + return [ + { + label: "Permissions", + path: "/settings/permissions", + key: "Permissions", + search + }, + { + label: "Subjects", + path: "/settings/permissions/subjects", + key: "Subjects", + search + } + ]; + }, [searchParams]); + + return ( + <> + + + Permissions + , + {activeTab}, + ...(headerAction ? [headerAction] : []) + ]} + /> + } + onRefresh={onRefresh} + loading={loading} + contentClass="p-0 h-full overflow-y-hidden" + > +
+
+ + {children} + +
+ +
+
+ + ); +} diff --git a/src/components/Permissions/PermissionsView.tsx b/src/components/Permissions/PermissionsView.tsx index d552e84a7f..616ea43c59 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -15,6 +15,9 @@ import { useEffect, useState } from "react"; import { Button } from ".."; import { FormikSelectDropdownOption } from "../Forms/Formik/FormikSelectDropdown"; import PermissionForm from "./ManagePermissions/Forms/PermissionForm"; +import PermissionAccessCheckModal, { + PermissionAccessCheckConfig +} from "./PermissionAccessCheckModal"; import PermissionsTable from "./PermissionsTable"; // Source: github.com/flanksource/duty/rbac/policy/policy.go @@ -82,6 +85,7 @@ type PermissionsViewProps = { newPermissionData?: Partial; showAddPermission?: boolean; onRefetch?: (refetch: () => void) => void; + accessCheckConfig?: PermissionAccessCheckConfig; }; export default function PermissionsView({ @@ -91,13 +95,15 @@ export default function PermissionsView({ hideSubjectColumn = false, newPermissionData, showAddPermission = false, - onRefetch + onRefetch, + accessCheckConfig }: PermissionsViewProps) { const [selectedPermission, setSelectedPermission] = useState(); const { pageSize, pageIndex } = useReactTablePaginationState(); const [sortState] = useReactTableSortState(); const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false); + const [isAccessCheckModalOpen, setIsAccessCheckModalOpen] = useState(false); const mappedSortBy = sortState[0]?.id === "created" @@ -153,13 +159,25 @@ export default function PermissionsView({
{showAddPermission && (
- +
+ + {accessCheckConfig ? ( + + ) : null} +
)}
@@ -193,6 +211,13 @@ export default function PermissionsView({ }} /> )} + {accessCheckConfig ? ( + + ) : null}
); } diff --git a/src/components/Permissions/ResourceAccessCard.tsx b/src/components/Permissions/ResourceAccessCard.tsx new file mode 100644 index 0000000000..c0c8495ee9 --- /dev/null +++ b/src/components/Permissions/ResourceAccessCard.tsx @@ -0,0 +1,146 @@ +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; + +type GlobalOverride = "allow" | "none" | "deny"; + +type Entity = { + id: string; + name: string; + namespace?: string; + icon?: string; +}; + +type PermissionAccessCardProps = { + entity: Entity; + globalOverride?: GlobalOverride; + onGlobalOverrideChange?: (value: GlobalOverride) => void; + onViewSubjects?: () => void; + isMutating?: boolean; + isSelected?: boolean; + showGlobalSwitch?: boolean; +}; + +const SWITCH_OPTIONS = ["Deny all", "Custom", "Allow all"]; +type SwitchOption = "Deny all" | "Custom" | "Allow all"; + +function toSwitchOption(value: GlobalOverride): SwitchOption { + switch (value) { + case "deny": + return "Deny all"; + case "allow": + return "Allow all"; + default: + return "Custom"; + } +} + +function toGlobalOverride(value: string): GlobalOverride { + switch (value) { + case "Deny all": + return "deny"; + case "Allow all": + return "allow"; + default: + return "none"; + } +} + +export default function ResourceAccessCard({ + entity, + globalOverride = "none", + onGlobalOverrideChange, + onViewSubjects, + isMutating = false, + isSelected = false, + showGlobalSwitch = true +}: PermissionAccessCardProps) { + const { icon, name, namespace } = entity; + const canOpenSubjects = + (showGlobalSwitch ? globalOverride === "none" : true) && + Boolean(onViewSubjects); + const isGlobalSwitchDisabled = isMutating || !onGlobalOverrideChange; + + const handleCardClick = () => { + if (!canOpenSubjects) { + return; + } + + onViewSubjects?.(); + }; + + const handleGlobalOverrideSwitchChange = (value: string) => { + if (!onGlobalOverrideChange) { + return; + } + + onGlobalOverrideChange(toGlobalOverride(value)); + }; + + return ( +
+
+
+ +
+ +
+
+
+ {name} +
+ {namespace && ( +
+ {namespace} +
+ )} +
+ + {showGlobalSwitch ? ( +
event.stopPropagation()} + > +
+ { + if (option === "Allow all") { + return "bg-blue-50 text-blue-700 ring-blue-200"; + } + + if (option === "Deny all") { + return "bg-red-50 text-red-700 ring-red-200"; + } + + return undefined; + }} + /> +
+
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/Permissions/ResourceList.tsx b/src/components/Permissions/ResourceList.tsx new file mode 100644 index 0000000000..f28947b858 --- /dev/null +++ b/src/components/Permissions/ResourceList.tsx @@ -0,0 +1,217 @@ +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { Switch as SegmentedSwitch } from "@flanksource-ui/ui/FormControls/Switch"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import type { + McpSubjectResource, + ResourceAccess +} from "./ResourceSelectorPanel"; +import ResourceRow from "./ResourceRow"; + +const BULK_OPTIONS = ["Deny All", "Custom", "Allow All"] as const; +type BulkOption = (typeof BULK_OPTIONS)[number]; + +const RESOURCE_SORT_OPTIONS = ["deny", "allow", "alphabetical"] as const; +type ResourceSortOption = (typeof RESOURCE_SORT_OPTIONS)[number]; +type ResourceSortDirection = "asc" | "desc"; + +const RESOURCE_SORT_OPTION_LABELS: Record = { + deny: "Deny", + allow: "Allow", + alphabetical: "Alphabetical" +}; + +function getSortRank(access: ResourceAccess, sortOption: ResourceSortOption) { + switch (sortOption) { + case "deny": + return access === "deny" ? 0 : access === "allow" ? 1 : 2; + case "allow": + return access === "allow" ? 0 : access === "deny" ? 1 : 2; + case "alphabetical": + default: + return 0; + } +} + +type ResourceListProps = { + title: string; + emptyMessage: string; + defaultIcon: string; + resources: McpSubjectResource[]; + bulkAccess: ResourceAccess; + onBulkAccessChange: (access: ResourceAccess) => void; + accessByResourceKey: Record; + effectiveAccessByResourceKey: Record; + hasEffectiveAccessResults: boolean; + getResourceKey: (resource: McpSubjectResource) => string; + isListLocked: boolean; + isSubmitting: boolean; + mutatingResourceIds: Record; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; +}; + +function ResourceList({ + title, + emptyMessage, + defaultIcon, + resources, + bulkAccess, + onBulkAccessChange, + accessByResourceKey, + effectiveAccessByResourceKey, + hasEffectiveAccessResults, + getResourceKey, + isListLocked, + isSubmitting, + mutatingResourceIds, + onSetResourceAccess +}: ResourceListProps) { + const [sort, setSort] = useState("alphabetical"); + const [sortDirection, setSortDirection] = + useState("asc"); + + const sortedResources = useMemo(() => { + return [...resources].sort((a, b) => { + const aAccess = accessByResourceKey[getResourceKey(a)] ?? "default"; + const bAccess = accessByResourceKey[getResourceKey(b)] ?? "default"; + + const rankDiff = getSortRank(aAccess, sort) - getSortRank(bAccess, sort); + + if (rankDiff !== 0) { + return sortDirection === "asc" ? rankDiff : -rankDiff; + } + + const nameDiff = (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { sensitivity: "base" } + ); + + return sortDirection === "asc" ? nameDiff : -nameDiff; + }); + }, [accessByResourceKey, getResourceKey, resources, sort, sortDirection]); + + const bulkOptionValue: BulkOption = + bulkAccess === "allow" + ? "Allow All" + : bulkAccess === "deny" + ? "Deny All" + : "Custom"; + + return ( +
+
+
+ {title} +
+
+
+ { + const access: ResourceAccess = + value === "Allow All" + ? "allow" + : value === "Deny All" + ? "deny" + : "default"; + onBulkAccessChange(access); + }} + getActiveItemClassName={(option) => + option === "Allow All" + ? "!bg-green-600 !text-white !ring-green-600" + : option === "Deny All" + ? "!bg-red-600 !text-white !ring-red-600" + : undefined + } + /> +
+ + + + +
+
+ +
+ {sortedResources.length === 0 ? ( +
{emptyMessage}
+ ) : ( + sortedResources.map((resource) => { + const key = getResourceKey(resource); + + return ( + + ); + }) + )} +
+
+ ); +} + +export default memo(ResourceList); diff --git a/src/components/Permissions/ResourceRow.tsx b/src/components/Permissions/ResourceRow.tsx new file mode 100644 index 0000000000..7faa0b0837 --- /dev/null +++ b/src/components/Permissions/ResourceRow.tsx @@ -0,0 +1,84 @@ +import EffectiveAccessBadge from "@flanksource-ui/components/Permissions/EffectiveAccessBadge"; +import TriStateAccessSwitch from "@flanksource-ui/components/Permissions/TriStateAccessSwitch"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { motion } from "motion/react"; +import { memo } from "react"; +import type { + McpSubjectResource, + ResourceAccess +} from "./ResourceSelectorPanel"; + +const ROW_LAYOUT_TRANSITION = { + type: "spring", + stiffness: 620, + damping: 42, + mass: 0.7 +} as const; + +type ResourceRowProps = { + resource: McpSubjectResource; + access: ResourceAccess; + defaultIcon: string; + showEffectiveBadge: boolean; + isAllowed: boolean; + isListLocked: boolean; + isSubmitting: boolean; + isMutating: boolean; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; +}; + +function ResourceRow({ + resource, + access, + defaultIcon, + showEffectiveBadge, + isAllowed, + isListLocked, + isSubmitting, + isMutating, + onSetResourceAccess +}: ResourceRowProps) { + return ( + +
+ +
+
+ {resource.displayName || resource.name} +
+ {resource.subtitle ? ( +
+ {resource.subtitle} +
+ ) : null} +
+
+ +
+ {showEffectiveBadge ? ( + + ) : null} + {!isListLocked ? ( + onSetResourceAccess(resource, nextAccess)} + /> + ) : null} +
+
+ ); +} + +export default memo(ResourceRow); diff --git a/src/components/Permissions/ResourceSelectorPanel.tsx b/src/components/Permissions/ResourceSelectorPanel.tsx new file mode 100644 index 0000000000..958a4635c5 --- /dev/null +++ b/src/components/Permissions/ResourceSelectorPanel.tsx @@ -0,0 +1,291 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import ResourceList from "@flanksource-ui/components/Permissions/ResourceList"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; +import { useCallback, useMemo, useState } from "react"; + +export type ResourceAccess = "deny" | "default" | "allow"; + +export type McpSubjectResource = { + id: string; + kind: "playbook" | "view"; + /** Canonical selector name used by object_selector (e.g. playbook.name / view.name). */ + name: string; + /** Optional UI label (e.g. title). Falls back to canonical `name`. */ + displayName?: string; + namespace?: string; + icon?: string; + subtitle?: string; +}; +type ResourceSelectorPanelProps = { + selectedSubject: PermissionSubject; + resources: McpSubjectResource[]; + permissions: PermissionsSummary[]; + effectiveAccessByResourceKey?: Record; + hasEffectiveAccessResults?: boolean; + isCheckingEffectiveAccess?: boolean; + isSubmitting?: boolean; + mutatingResourceIds?: Record; + onCheckEffectiveAccess?: () => void; + onSetResourceAccess: ( + resource: McpSubjectResource, + access: ResourceAccess + ) => Promise | void; + onSetManyResourceAccess: ( + resources: McpSubjectResource[], + access: ResourceAccess + ) => Promise | void; +}; + +function getRefsForPermission( + permission: PermissionsSummary, + kind: "playbook" | "view" +) { + return kind === "playbook" + ? (permission.object_selector?.playbooks ?? []) + : (permission.object_selector?.views ?? []); +} + +function permissionMatchesResource( + permission: PermissionsSummary, + resource: McpSubjectResource +) { + const refs = getRefsForPermission(permission, resource.kind); + + return refs.some((ref) => { + if (!ref?.name || ref.name === "*") { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +function getAccessState( + permissions: PermissionsSummary[], + subject: PermissionSubject, + resource: McpSubjectResource +): { + access: ResourceAccess; +} { + const subjectType = mapSubjectType(subject.type); + + const direct = permissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.source === "mcp_settings" && + permission.subject === subject.id && + permission.subject_type === subjectType && + permissionMatchesResource(permission, resource) + ); + + if (direct.length > 0) { + return { + access: direct.some((permission) => permission.deny === true) + ? "deny" + : "allow" + }; + } + + return { access: "default" }; +} + +function getResourceKey(resource: McpSubjectResource) { + return `${resource.kind}:${resource.id}`; +} + +export default function ResourceSelectorPanel({ + selectedSubject, + resources, + permissions, + effectiveAccessByResourceKey = {}, + hasEffectiveAccessResults = false, + isCheckingEffectiveAccess = false, + isSubmitting = false, + mutatingResourceIds = {}, + onCheckEffectiveAccess, + onSetResourceAccess, + onSetManyResourceAccess +}: ResourceSelectorPanelProps) { + const [resourceSearch, setResourceSearch] = useState(""); + + const normalizedSearch = resourceSearch.trim().toLowerCase(); + + const filteredResources = useMemo(() => { + return resources.filter((resource) => { + if (!normalizedSearch) { + return true; + } + + const haystack = + `${resource.displayName || resource.name} ${resource.name} ${resource.subtitle || ""} ${resource.namespace || ""}`.toLowerCase(); + return haystack.includes(normalizedSearch); + }); + }, [normalizedSearch, resources]); + + const accessByResourceKey = useMemo(() => { + const byKey: Record = {}; + + for (const resource of filteredResources) { + byKey[getResourceKey(resource)] = getAccessState( + permissions, + selectedSubject, + resource + ).access; + } + + return byKey; + }, [filteredResources, permissions, selectedSubject]); + + const resourcesByType = useMemo(() => { + const playbooks: McpSubjectResource[] = []; + const views: McpSubjectResource[] = []; + + for (const resource of filteredResources) { + if (resource.kind === "playbook") { + playbooks.push(resource); + } else { + views.push(resource); + } + } + + return { playbooks, views }; + }, [filteredResources]); + + const bulkAccessByKind = useMemo(() => { + const subjectType = mapSubjectType(selectedSubject.type); + + const getWildcardAccessByKind = ( + kind: "playbook" | "view" + ): ResourceAccess => { + const wildcardPermissions = permissions.filter((permission) => { + if ( + permission.action !== "mcp:run" || + permission.source !== "mcp_settings" || + permission.subject !== selectedSubject.id || + permission.subject_type !== subjectType + ) { + return false; + } + + return getRefsForPermission(permission, kind).some( + (ref) => ref?.name === "*" && !ref?.namespace + ); + }); + + if (wildcardPermissions.length === 0) { + return "default"; + } + + return wildcardPermissions.some((permission) => permission.deny === true) + ? "deny" + : "allow"; + }; + + return { + playbook: getWildcardAccessByKind("playbook"), + view: getWildcardAccessByKind("view") + }; + }, [permissions, selectedSubject]); + + const onSetPlaybookBulkAccess = useCallback( + (access: ResourceAccess) => { + onSetManyResourceAccess(resourcesByType.playbooks, access); + }, + [onSetManyResourceAccess, resourcesByType.playbooks] + ); + + const onSetViewBulkAccess = useCallback( + (access: ResourceAccess) => { + onSetManyResourceAccess(resourcesByType.views, access); + }, + [onSetManyResourceAccess, resourcesByType.views] + ); + + return ( +
+
+
+
+ +
+
+ {selectedSubject.name} +
+
+
+ + {onCheckEffectiveAccess ? ( + + ) : null} +
+
+ +
+
+
+ setResourceSearch(event.target.value)} + /> +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/src/components/Permissions/SubjectAccessCard.tsx b/src/components/Permissions/SubjectAccessCard.tsx new file mode 100644 index 0000000000..6cb83b53a5 --- /dev/null +++ b/src/components/Permissions/SubjectAccessCard.tsx @@ -0,0 +1,95 @@ +import SubjectAvatar, { + PermissionSubjectType +} from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; + +type AccessLevel = "deny" | "default" | "allow"; +type SwitchOption = "Deny" | "Default" | "Allow"; + +const ACCESS_TO_OPTION: Record = { + deny: "Deny", + default: "Default", + allow: "Allow" +}; + +const OPTION_TO_ACCESS: Record = { + Deny: "deny", + Default: "default", + Allow: "allow" +}; + +const SWITCH_OPTIONS: SwitchOption[] = ["Deny", "Default", "Allow"]; + +type SubjectAccessCardProps = { + user: { + id: string; + name?: string; + email?: string; + avatar?: string; + type?: PermissionSubjectType; + }; + action: string; + object: string; + access: AccessLevel; + onChangeAccess: (access: AccessLevel) => void; + isMutating?: boolean; +}; + +export default function SubjectAccessCard({ + user, + action, + object, + access, + onChangeAccess, + isMutating = false +}: SubjectAccessCardProps) { + return ( +
+
+ + +
+
+
+ {user.name} +
+ {user.email && ( +
+ {user.email} +
+ )} +
+ +
+ onChangeAccess(OPTION_TO_ACCESS[option])} + aria-label={`${action} on ${object} for ${user.name ?? user.id}`} + getActiveItemClassName={(option) => + option === "Allow" + ? "bg-blue-50 text-blue-700 ring-blue-200" + : option === "Deny" + ? "bg-red-50 text-red-700 ring-red-200" + : undefined + } + /> +
+
+
+
+ ); +} diff --git a/src/components/Permissions/SubjectAvatar.tsx b/src/components/Permissions/SubjectAvatar.tsx new file mode 100644 index 0000000000..4748aeaa8f --- /dev/null +++ b/src/components/Permissions/SubjectAvatar.tsx @@ -0,0 +1,91 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; +import clsx from "clsx"; +import { IconType } from "react-icons"; +import { HiBadgeCheck, HiKey, HiUserGroup, HiUsers } from "react-icons/hi"; + +export type PermissionSubjectType = PermissionSubject["type"]; + +type SubjectAvatarSize = "xs" | "md"; + +type SubjectAvatarProps = { + subject: Pick; + size?: SubjectAvatarSize; + className?: string; +}; + +const SUBJECT_TYPE_ICON_CONFIG: Record< + Exclude, + { + Icon: IconType; + colors: string; + } +> = { + team: { + Icon: HiUserGroup, + colors: "bg-blue-100 text-blue-700" + }, + permission_subject_group: { + Icon: HiUsers, + colors: "bg-violet-100 text-violet-700" + }, + role: { + Icon: HiBadgeCheck, + colors: "bg-indigo-50 text-indigo-700" + }, + access_token_person: { + Icon: HiKey, + colors: "bg-indigo-50 text-indigo-700" + } +}; + +const SIZE_CLASSNAMES: Record< + SubjectAvatarSize, + { wrapper: string; icon: string } +> = { + xs: { + wrapper: "h-5 w-5 rounded-full", + icon: "h-3 w-3" + }, + md: { + wrapper: "h-8 w-8 rounded-md", + icon: "h-4 w-4" + } +}; + +export default function SubjectAvatar({ + subject, + size = "xs", + className +}: SubjectAvatarProps) { + if (subject.type === "person") { + return ( + span]:!text-[10px]" : "[&>span]:!text-[10px]", + className + ) + }} + /> + ); + } + + const { Icon, colors } = SUBJECT_TYPE_ICON_CONFIG[subject.type]; + const sizeClassName = SIZE_CLASSNAMES[size]; + + return ( + + + + ); +} diff --git a/src/components/Permissions/SubjectSelectorPanel.tsx b/src/components/Permissions/SubjectSelectorPanel.tsx new file mode 100644 index 0000000000..5feb281a5e --- /dev/null +++ b/src/components/Permissions/SubjectSelectorPanel.tsx @@ -0,0 +1,642 @@ +import { + fetchAllPermissionSubjects, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { + fetchEffectiveResourceSubjectAccess, + SubjectAccessReviewAction +} from "@flanksource-ui/api/services/rbac"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import TriStateAccessSwitch from "@flanksource-ui/components/Permissions/TriStateAccessSwitch"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@flanksource-ui/components/ui/tooltip"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, Check, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +export type SubjectAccess = "deny" | "default" | "allow"; + +const BULK_OPTIONS = ["Deny All", "Custom", "Allow all"] as const; +type BulkOption = (typeof BULK_OPTIONS)[number]; + +const SUBJECT_SORT_OPTIONS = [ + "deny", + "allow", + "custom", + "alphabetical" +] as const; +type SubjectSortOption = (typeof SUBJECT_SORT_OPTIONS)[number]; +type SubjectSortDirection = "asc" | "desc"; + +const SUBJECT_SORT_OPTION_LABELS: Record = { + deny: "Deny", + allow: "Allow", + custom: "Custom", + alphabetical: "Alphabetical" +}; + +const ROW_LAYOUT_TRANSITION = { + type: "spring", + stiffness: 620, + damping: 42, + mass: 0.7 +} as const; + +type SubjectSelectorPanelProps = { + title?: string; + description?: string; + headerEntity?: { + name: string; + icon?: string; + }; + effectiveAccessResource?: { + id: string; + type: "playbook" | "view"; + action?: SubjectAccessReviewAction; + }; + preselectedSubjectAccess?: Record; + isSubmitting?: boolean; + isBulkSubmitting?: boolean; + mutatingSubjectId?: string | null; + bulkAccess?: SubjectAccess; + onSetBulkAccess?: (access: SubjectAccess) => Promise | void; + onSetSubjectAccess: ( + subject: PermissionSubject, + access: SubjectAccess + ) => Promise | void; + onSetManySubjectAccess?: ( + selections: Array<{ subject: PermissionSubject; access: "allow" | "deny" }> + ) => Promise | void; +}; + +function getSubjectSortRank( + access: SubjectAccess, + sortOption: SubjectSortOption +) { + switch (sortOption) { + case "deny": + return access === "deny" ? 0 : access === "allow" ? 1 : 2; + case "allow": + return access === "allow" ? 0 : access === "deny" ? 1 : 2; + case "custom": + return access === "default" ? 1 : 0; + case "alphabetical": + default: + return 0; + } +} + +export default function SubjectSelectorPanel({ + title, + description, + effectiveAccessResource, + preselectedSubjectAccess = {}, + isSubmitting = false, + isBulkSubmitting = false, + mutatingSubjectId, + headerEntity, + bulkAccess, + onSetBulkAccess, + onSetSubjectAccess, + onSetManySubjectAccess +}: SubjectSelectorPanelProps) { + const [search, setSearch] = useState(""); + const [subjectSort, setSubjectSort] = + useState("alphabetical"); + const [subjectSortDirection, setSubjectSortDirection] = + useState("asc"); + const [selectedAccessById, setSelectedAccessById] = useState< + Record + >({}); + const [ + hasTriggeredEffectiveAccessCheck, + setHasTriggeredEffectiveAccessCheck + ] = useState(false); + + const debouncedSearch = + useDebouncedValue(search, 250)?.trim().toLowerCase() ?? ""; + + const { + data: subjects = [], + isLoading, + isFetching + } = useQuery({ + queryKey: ["mcp", "subject-selector", "all-subjects"], + queryFn: fetchAllPermissionSubjects, + staleTime: 60_000 + }); + + const { + data: effectiveSubjectAccessResponse, + isFetching: isCheckingEffectiveAccess, + refetch: refetchEffectiveSubjectAccess + } = useQuery({ + queryKey: [ + "mcp", + "subject-selector", + "effective-access", + effectiveAccessResource?.type ?? "none", + effectiveAccessResource?.id ?? "none", + subjects.length + ], + enabled: false, + queryFn: async () => { + if (!effectiveAccessResource) { + return { + resource: { id: "", type: "playbook" as const }, + action: "mcp:run" as const, + results: [] as Array<{ subjectId: string; allowed: boolean }> + }; + } + + return fetchEffectiveResourceSubjectAccess({ + resource: { + id: effectiveAccessResource.id, + type: effectiveAccessResource.type + }, + action: effectiveAccessResource.action ?? "mcp:run", + subjects: subjects.map((subject) => subject.id) + }); + } + }); + + const normalizedPreselectedAccess = useMemo( + () => + Object.fromEntries( + Object.entries(preselectedSubjectAccess) + .filter(([, access]) => access === "allow" || access === "deny") + .sort(([a], [b]) => a.localeCompare(b)) + ) as Record, + [preselectedSubjectAccess] + ); + + const preselectedAccessSignature = useMemo( + () => JSON.stringify(normalizedPreselectedAccess), + [normalizedPreselectedAccess] + ); + + useEffect(() => { + const parsed = preselectedAccessSignature + ? (JSON.parse(preselectedAccessSignature) as Record< + string, + "allow" | "deny" + >) + : {}; + + const next: Record = {}; + for (const [id, access] of Object.entries(parsed)) { + next[id] = access; + } + + setSelectedAccessById(next); + }, [preselectedAccessSignature]); + + useEffect(() => { + setHasTriggeredEffectiveAccessCheck(false); + }, [effectiveAccessResource?.type, effectiveAccessResource?.id]); + + const effectiveAccessBySubjectId = useMemo(() => { + const map: Record = {}; + + for (const result of effectiveSubjectAccessResponse?.results ?? []) { + map[result.subjectId] = result.allowed; + } + + return map; + }, [effectiveSubjectAccessResponse?.results]); + + const sortedSubjects = useMemo(() => { + const query = debouncedSearch; + + return subjects + .filter((subject) => { + if (!query) { + return true; + } + return subject.name.toLowerCase().includes(query); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + const aAccess = selectedAccessById[a.id] ?? "default"; + const bAccess = selectedAccessById[b.id] ?? "default"; + const rankDiff = + getSubjectSortRank(aAccess, subjectSort) - + getSubjectSortRank(bAccess, subjectSort); + + if (rankDiff !== 0) { + return subjectSortDirection === "asc" ? rankDiff : -rankDiff; + } + + const nameDiff = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + + return subjectSortDirection === "asc" ? nameDiff : -nameDiff; + }); + }, [ + debouncedSearch, + selectedAccessById, + subjectSort, + subjectSortDirection, + subjects + ]); + + const displayedSubjects = sortedSubjects; + + const groupedDisplayedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of displayedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()) + .sort((a, b) => SUBJECT_TYPE_ORDER[a[0]] - SUBJECT_TYPE_ORDER[b[0]]) + .map(([type, groupSubjects]) => ({ + type, + subjects: groupSubjects + })); + }, [displayedSubjects]); + + const bulkAccessFromSelection = useMemo(() => { + if (displayedSubjects.length === 0) { + return "default"; + } + + const accessValues = displayedSubjects.map( + (subject) => selectedAccessById[subject.id] ?? "default" + ); + + if (accessValues.every((value) => value === "allow")) { + return "allow"; + } + + if (accessValues.every((value) => value === "deny")) { + return "deny"; + } + + if (accessValues.every((value) => value === "default")) { + return "default"; + } + + return "default"; + }, [displayedSubjects, selectedAccessById]); + + const resolvedBulkAccess = bulkAccess ?? bulkAccessFromSelection; + + const bulkOptionValue: BulkOption = + resolvedBulkAccess === "allow" + ? "Allow all" + : resolvedBulkAccess === "deny" + ? "Deny All" + : "Custom"; + + const setBulkSubjectAccess = async (access: SubjectAccess) => { + if (onSetBulkAccess) { + await onSetBulkAccess(access); + return; + } + + if (displayedSubjects.length === 0) { + return; + } + + const nextSelections = { ...selectedAccessById }; + + for (const subject of displayedSubjects) { + if (access === "default") { + delete nextSelections[subject.id]; + } else { + nextSelections[subject.id] = access; + } + } + + setSelectedAccessById(nextSelections); + + if (onSetManySubjectAccess) { + const subjectsById = new Map( + subjects.map((subject) => [subject.id, subject] as const) + ); + + const selections = Object.entries(nextSelections) + .filter(([, value]) => value === "allow" || value === "deny") + .map(([subjectId, value]) => { + const subject = subjectsById.get(subjectId); + if (!subject) { + return null; + } + + return { + subject, + access: value as "allow" | "deny" + }; + }) + .filter( + ( + entry + ): entry is { + subject: PermissionSubject; + access: "allow" | "deny"; + } => !!entry + ); + + await onSetManySubjectAccess(selections); + return; + } + + await Promise.all( + displayedSubjects.map((subject) => onSetSubjectAccess(subject, access)) + ); + }; + + const setSubjectAccess = ( + subject: PermissionSubject, + access: SubjectAccess + ) => { + setSelectedAccessById((prev) => { + const next = { ...prev }; + + if (access === "default") { + delete next[subject.id]; + } else { + next[subject.id] = access; + } + + return next; + }); + + void onSetSubjectAccess(subject, access); + }; + + const isListLocked = + Boolean(onSetBulkAccess) && + (resolvedBulkAccess === "allow" || resolvedBulkAccess === "deny"); + + const renderEffectiveAccessIcon = (subject: PermissionSubject) => { + if (!hasTriggeredEffectiveAccessCheck) { + return null; + } + + const allowed = effectiveAccessBySubjectId[subject.id] === true; + + return ( + + + + {allowed ? ( + + ) : ( + + )} + + + + Effective access: {allowed ? "Allowed" : "Denied"} + + + ); + }; + + return ( +
+
+
+
+ {headerEntity ? ( + + + + ) : null} +
+
+ {headerEntity?.name || title} +
+ {!headerEntity && description ? ( +
+ {description} +
+ ) : null} +
+
+ + {effectiveAccessResource ? ( + + ) : null} +
+ +
+
Global permission
+ +
+ { + const access: SubjectAccess = + value === "Allow all" + ? "allow" + : value === "Deny All" + ? "deny" + : "default"; + void setBulkSubjectAccess(access); + }} + getActiveItemClassName={(option) => + option === "Allow all" + ? "!bg-green-600 !text-white !ring-green-600" + : option === "Deny All" + ? "!bg-red-600 !text-white !ring-red-600" + : undefined + } + /> +
+
+
+ +
+
+
+ setSearch(event.target.value)} + /> + + + + +
+ +
+ {isLoading || isFetching ? ( +
+
+ Loading subjects... +
+ ) : displayedSubjects.length > 0 ? ( + groupedDisplayedSubjects.map((group) => ( +
+
+ {TYPE_LABELS[group.type] ?? group.type} +
+ +
+ {group.subjects.map((subject) => ( + +
+ +
+ {subject.name} +
+
+ +
+ {renderEffectiveAccessIcon(subject)} + {!isListLocked ? ( + + setSubjectAccess(subject, next) + } + /> + ) : null} +
+
+ ))} +
+
+ )) + ) : ( +
No subjects found
+ )} +
+
+
+
+ ); +} diff --git a/src/components/Permissions/TriStateAccessSwitch.tsx b/src/components/Permissions/TriStateAccessSwitch.tsx new file mode 100644 index 0000000000..52a00012ce --- /dev/null +++ b/src/components/Permissions/TriStateAccessSwitch.tsx @@ -0,0 +1,78 @@ +type ResourceAccess = "deny" | "default" | "allow"; + +type TriStateAccessSwitchProps = { + value: ResourceAccess; + onChange: (value: ResourceAccess) => void; + disabled?: boolean; +}; + +const ACCESS_LABEL: Record = { + deny: "Deny", + default: "Default", + allow: "Allow" +}; + +const ACCESS_LABEL_CLASSNAME: Record = { + deny: "text-red-600", + default: "text-gray-500", + allow: "text-green-600" +}; + +const TRACK_CLASSNAME: Record = { + deny: "bg-red-500", + default: "bg-gray-400", + allow: "bg-green-600" +}; + +const POSITION_BY_ACCESS: Record = { + deny: 0, + default: 1, + allow: 2 +}; + +const ACCESS_ORDER: ResourceAccess[] = ["deny", "default", "allow"]; + +export default function TriStateAccessSwitch({ + value, + onChange, + disabled = false +}: TriStateAccessSwitchProps) { + const position = POSITION_BY_ACCESS[value] * 14; + + return ( +
+
+ + + {ACCESS_ORDER.map((option) => ( +
+ + + {ACCESS_LABEL[value]} + +
+ ); +} diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx index 14af3d5d24..fee2589995 100644 --- a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -1,3 +1,4 @@ +import { SubjectAccessReviewAction } from "@flanksource-ui/api/services/rbac"; import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; import PermissionsView from "@flanksource-ui/components/Permissions/PermissionsView"; import { Modal } from "@flanksource-ui/ui/Modal"; @@ -12,6 +13,13 @@ type PlaybookPermissionsModalProps = { onClose: () => void; }; +const playbookAccessReviewActions: SubjectAccessReviewAction[] = [ + "read", + "playbook:run", + "playbook:cancel", + "playbook:approve" +]; + export default function PlaybookPermissionsModal({ playbook, isOpen, @@ -99,6 +107,17 @@ export default function PlaybookPermissionsModal({ hideResourceColumn permissionRequest={inboundPermissionRequest} showAddPermission + accessCheckConfig={{ + resource: { + type: "playbook", + id: playbook.id, + name: `${playbook.namespace}/${playbook.name}` + }, + actions: playbookAccessReviewActions, + title: "Playbook Access Check", + description: + "Select a subject and action to check access for this playbook." + }} newPermissionData={{ playbook_id: playbook.id }} diff --git a/src/components/Tokens/Add/CreateTokenForm.tsx b/src/components/Tokens/Add/CreateTokenForm.tsx index 60cc4c92d5..99cf9ec875 100644 --- a/src/components/Tokens/Add/CreateTokenForm.tsx +++ b/src/components/Tokens/Add/CreateTokenForm.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { Form, Formik } from "formik"; +import { useState } from "react"; import { FaSpinner } from "react-icons/fa"; import { createToken, @@ -12,7 +13,7 @@ import { Button } from "../../../ui/Buttons/Button"; import { Modal } from "../../../ui/Modal"; import FormikTextInput from "../../Forms/Formik/FormikTextInput"; import FormikSelectDropdown from "../../Forms/Formik/FormikSelectDropdown"; -import { toastError, toastSuccess } from "../../Toast/toast"; +import { toastSuccess } from "../../Toast/toast"; import { OBJECTS, getActionsForObject, @@ -20,6 +21,7 @@ import { } from "../tokenUtils"; import TokenScopeFieldsGroup from "./TokenScopeFieldsGroup"; import FormikCheckbox from "@flanksource-ui/components/Forms/Formik/FormikCheckbox"; +import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; export type TokenFormValues = CreateTokenRequest & { objectActions: Record; @@ -59,13 +61,16 @@ export function CreateTokenFormContent({ showFooter = true, formId }: CreateTokenFormContentProps) { + const [submitError, setSubmitError] = useState(null); + const { mutate: createTokenMutation, isLoading } = useMutation({ mutationFn: createToken, - onSuccess: (data) => { + onSuccess: () => { + setSubmitError(null); toastSuccess("Token created successfully"); }, - onError: (error: any) => { - toastError(error.message || "Failed to create token"); + onError: (error: unknown) => { + setSubmitError(error); } }); @@ -121,6 +126,8 @@ export function CreateTokenFormContent({ scope: selectedScopes }; + setSubmitError(null); + createTokenMutation(tokenRequest, { onSuccess: (data) => { onSuccess(data, values); @@ -149,12 +156,12 @@ export function CreateTokenFormContent({ {({ handleSubmit, isValid }) => (
-
-
-
+
+
+
+ {submitError != null && ( + + )} {showFooter && (
{selectedScope === "Custom" && ( -
+
{OBJECTS.map((object) => ( void; + showHideAgentsToggle?: boolean; + defaultHideAgents?: boolean; + tokenIdSearchParamKey?: string; }; export default function TokensTable({ tokens, isLoading, isRefetching, - refresh + refresh, + showHideAgentsToggle = true, + defaultHideAgents = true, + tokenIdSearchParamKey = "id" }: TokensTableProps) { const [searchParams, setSearchParams] = useSearchParams(); const columns = useMemo(() => tokensTableColumns, []); - const tokenId = searchParams.get("id") ?? undefined; + const tokenId = searchParams.get(tokenIdSearchParamKey) ?? undefined; const selectedToken = useMemo(() => { return tokens.find((token) => token.id === tokenId); }, [tokens, tokenId]); - const [hideAgents, setHideAgents] = useState(true); + const [hideAgents, setHideAgents] = useState(defaultHideAgents); const filteredTokens = useMemo(() => { if (hideAgents) { return tokens.filter((token) => !token.name.startsWith("agent-")); @@ -38,14 +44,16 @@ export default function TokensTable({ return (
- { - setHideAgents(val); - }} - /> + {showHideAgentsToggle && ( + { + setHideAgents(val); + }} + /> + )} { - searchParams.set("id", token.id); + searchParams.set(tokenIdSearchParamKey, token.id); setSearchParams(searchParams); }} /> @@ -67,7 +75,7 @@ export default function TokensTable({ if (refresh) { refresh(); } - searchParams.delete("id"); + searchParams.delete(tokenIdSearchParamKey); setSearchParams(searchParams); }} /> diff --git a/src/components/Users/SetupMcpModal.tsx b/src/components/Users/SetupMcpModal.tsx index 9cfbd1b661..496bded592 100644 --- a/src/components/Users/SetupMcpModal.tsx +++ b/src/components/Users/SetupMcpModal.tsx @@ -77,9 +77,10 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) { title="Setup MCP" onClose={handleClose} open={isOpen} - bodyClass="flex h-[70vh] min-h-[620px] max-h-[70vh] w-full flex-1 flex-col overflow-hidden" + allowBodyScroll + bodyClass="flex w-full flex-col" > -
+

Choose how MCP should authenticate @@ -90,16 +91,16 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) {

-
+
setMode(tab as SetupMode)} - contentClassName="flex min-h-0 flex-1 flex-col overflow-y-auto border border-t-0 border-gray-300 bg-white" + contentClassName="flex flex-col border border-t-0 border-gray-300 bg-white" > ( -
+
{mcpUsageInstructionsByClient[activeClient] && ( -
+
{mcpUsageInstructionsByClient[activeClient]}
)} @@ -131,7 +132,7 @@ export default function SetupMcpModal({ isOpen, onClose }: Props) { {mcpTokenResponse ? ( , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/src/lib/permissions/mcpPermissionCardMappings.ts b/src/lib/permissions/mcpPermissionCardMappings.ts new file mode 100644 index 0000000000..9421d268fb --- /dev/null +++ b/src/lib/permissions/mcpPermissionCardMappings.ts @@ -0,0 +1,208 @@ +import { + PermissionSubject, + MCP_SETTINGS_PERMISSION_SOURCE +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; + +export const EVERYONE_SUBJECT_ID = "everyone"; +export const EVERYONE_SUBJECT_TYPE = "group"; + +/** + * Map a PermissionSubject type to the subject_type used in permission records. + */ +export function mapSubjectType(type: PermissionSubject["type"]) { + if (type === "permission_subject_group") { + return "group" as const; + } + + if (type === "role") { + return "role" as const; + } + + if (type === "access_token_person") { + return "person" as const; + } + + return type; +} + +export type NamespacedResource = { + id: string; + name: string; + namespace?: string; +}; + +export type NamespacedRef = { + name?: string; + namespace?: string; +}; + +export type PermissionBuckets = { + users: PermissionsSummary[]; + groups: PermissionsSummary[]; +}; + +export function buildSubjectLookup(subjects: PermissionSubject[]) { + return Object.fromEntries( + subjects.map((subject) => [ + subject.id, + { name: subject.name, type: subject.type } + ]) + ); +} + +export function permissionMatchesResource( + permission: PermissionsSummary, + resource: TResource, + getRefs: (permission: PermissionsSummary) => NamespacedRef[] +) { + const refs = getRefs(permission); + + return refs.some((ref) => { + if (!ref?.name) { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +function createResourceIndexes( + resources: TResource[] +) { + const byNamespacedRef = new Map(); + const byName = new Map(); + + for (const resource of resources) { + byNamespacedRef.set( + `${resource.namespace || ""}/${resource.name}`, + resource + ); + + const existing = byName.get(resource.name) ?? []; + existing.push(resource); + byName.set(resource.name, existing); + } + + return { byNamespacedRef, byName }; +} + +function resolveResourcesForRef( + ref: NamespacedRef, + indexes: ReturnType> +) { + if (!ref?.name) { + return []; + } + + if (ref.namespace) { + const matched = indexes.byNamespacedRef.get(`${ref.namespace}/${ref.name}`); + return matched ? [matched] : []; + } + + return indexes.byName.get(ref.name) ?? []; +} + +export function buildPermissionAccessCardMaps< + TResource extends NamespacedResource +>({ + resources, + permissions, + getRefs, + action = "mcp:run", + source = MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId = "everyone", + everyoneSubjectType = "group" +}: { + resources: TResource[]; + permissions: PermissionsSummary[]; + getRefs: (permission: PermissionsSummary) => NamespacedRef[]; + action?: string; + source?: string; + everyoneSubjectId?: string; + everyoneSubjectType?: string; +}) { + const permissionsByResource = new Map(); + const globalOverrideByResource = new Map(); + + const indexes = createResourceIndexes(resources); + + for (const permission of permissions) { + const refs = getRefs(permission); + if (refs.length === 0) { + continue; + } + + const matchedResources: TResource[] = []; + const matchedIds = new Set(); + + for (const ref of refs) { + const resourcesForRef = resolveResourcesForRef(ref, indexes); + for (const resource of resourcesForRef) { + if (matchedIds.has(resource.id)) { + continue; + } + matchedIds.add(resource.id); + matchedResources.push(resource); + } + } + + if (matchedResources.length === 0) { + continue; + } + + const isGlobalOverride = + permission.action === action && + permission.subject_type === everyoneSubjectType && + permission.subject === everyoneSubjectId && + permission.source === source; + + if (isGlobalOverride) { + for (const resource of matchedResources) { + const next = permission.deny === true ? "deny" : "allow"; + const current = globalOverrideByResource.get(resource.id); + globalOverrideByResource.set( + resource.id, + current === "deny" || next === "deny" ? "deny" : "allow" + ); + } + continue; + } + + if ( + permission.deny === true || + (permission.subject_type === everyoneSubjectType && + permission.subject === everyoneSubjectId) + ) { + continue; + } + + for (const resource of matchedResources) { + const current = permissionsByResource.get(resource.id) ?? { + users: [], + groups: [] + }; + + if (permission.subject_type === "person") { + current.users.push(permission); + } else if ( + permission.subject_type === "team" || + permission.subject_type === "group" || + permission.subject_type === "role" + ) { + current.groups.push(permission); + } + + permissionsByResource.set(resource.id, current); + } + } + + return { + permissionsByResource, + globalOverrideByResource + }; +} diff --git a/src/lib/permissions/useMcpResourcePermissions.ts b/src/lib/permissions/useMcpResourcePermissions.ts new file mode 100644 index 0000000000..f4212dd093 --- /dev/null +++ b/src/lib/permissions/useMcpResourcePermissions.ts @@ -0,0 +1,528 @@ +import { + addPermission, + deletePermission, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + buildPermissionAccessCardMaps, + EVERYONE_SUBJECT_ID, + EVERYONE_SUBJECT_TYPE, + mapSubjectType, + NamespacedRef, + NamespacedResource, + permissionMatchesResource +} from "./mcpPermissionCardMappings"; + +export type McpResourcePermissionsConfig = + { + resources: TResource[]; + isResourcesLoading: boolean; + isResourcesRefetching: boolean; + refetchResources: () => void; + permissionsQueryKey: string[]; + fetchPermissions: () => Promise; + getRefs: (permission: PermissionsSummary) => NamespacedRef[]; + /** Key used in `object_selector` payloads, e.g. `"playbooks"` or `"views"`. */ + objectSelectorKey: string; + }; + +export type SubjectAccessSelection = { + subject: PermissionSubject; + access: "allow" | "deny"; +}; + +export function useMcpResourcePermissions< + TResource extends NamespacedResource +>({ + resources, + isResourcesLoading, + isResourcesRefetching, + refetchResources, + permissionsQueryKey, + fetchPermissions, + getRefs, + objectSelectorKey +}: McpResourcePermissionsConfig) { + const { user } = useUser(); + const [selectedResourceId, setSelectedResourceId] = useState( + null + ); + const [mutatingResourceId, setMutatingResourceId] = useState( + null + ); + const [mutatingSubjectId, setMutatingSubjectId] = useState( + null + ); + + // ── Queries ────────────────────────────────────────────────────────── + + const { + data: permissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: permissionsQueryKey, + queryFn: fetchPermissions + }); + + // ── Derived data ──────────────────────────────────────────────────── + + const { permissionsByResource, globalOverrideByResource } = useMemo( + () => + buildPermissionAccessCardMaps({ + resources, + permissions, + getRefs, + source: MCP_SETTINGS_PERMISSION_SOURCE, + everyoneSubjectId: EVERYONE_SUBJECT_ID, + everyoneSubjectType: EVERYONE_SUBJECT_TYPE + }), + [permissions, resources, getRefs] + ); + + const selectedResource = useMemo( + () => resources.find((r) => r.id === selectedResourceId), + [resources, selectedResourceId] + ); + + const preselectedSubjectAccess = useMemo(() => { + const accessBySubjectId: Record = {}; + + if (!selectedResource) { + return accessBySubjectId; + } + + for (const permission of permissions) { + if ( + !permission.subject || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + (permission.subject_type !== "person" && + permission.subject_type !== "team" && + permission.subject_type !== "group" && + permission.subject_type !== "role") || + !permissionMatchesResource(permission, selectedResource, getRefs) + ) { + continue; + } + + const subjectId = permission.subject; + const nextAccess = permission.deny === true ? "deny" : "allow"; + const currentAccess = accessBySubjectId[subjectId]; + + accessBySubjectId[subjectId] = + currentAccess === "deny" || nextAccess === "deny" ? "deny" : "allow"; + } + + return accessBySubjectId; + }, [permissions, selectedResource, getRefs]); + + // ── Mutations ─────────────────────────────────────────────────────── + + const { + mutate: setGlobalOverrideMutation, + isLoading: isUpdatingGlobalOverride + } = useMutation({ + mutationFn: async ({ + resource, + override + }: { + resource: TResource; + override: "allow" | "none" | "deny"; + }) => { + const latestPermissions = await fetchPermissions(); + + const matchingOverrides = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject_type === EVERYONE_SUBJECT_TYPE && + p.subject === EVERYONE_SUBJECT_ID && + p.id && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + if (override === "none") { + await Promise.all(matchingOverrides.map((p) => deletePermission(p.id))); + return; + } + + const targetDeny = override === "deny"; + const [canonicalOverride, ...duplicateOverrides] = matchingOverrides; + + if (duplicateOverrides.length > 0) { + await Promise.all( + duplicateOverrides.map((p) => deletePermission(p.id)) + ); + } + + if (canonicalOverride) { + if (canonicalOverride.deny !== targetDeny) { + await updatePermission({ + id: canonicalOverride.id, + deny: targetDeny + } as any); + } + return; + } + + await addPermission({ + object_selector: { + [objectSelectorKey]: [ + { name: resource.name, namespace: resource.namespace } + ] + }, + action: "mcp:run", + subject: EVERYONE_SUBJECT_ID, + subject_type: EVERYONE_SUBJECT_TYPE, + deny: targetDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + }, + onSettled: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { + mutateAsync: allowSelectiveAccessMutation, + isLoading: isAllowingSelective + } = useMutation({ + mutationFn: async ({ + resource, + subjects + }: { + resource: TResource; + subjects: PermissionSubject[] | SubjectAccessSelection[]; + }) => { + // Re-fetch to avoid acting on stale data from the render closure + const latestPermissions = await fetchPermissions(); + + const normalizedSelections: SubjectAccessSelection[] = ( + subjects as Array + ).map((entry) => { + if ("subject" in entry) { + return entry; + } + + return { + subject: entry, + access: "allow" + }; + }); + + const desiredAccessByKey = new Map( + normalizedSelections.map((selection) => [ + `${mapSubjectType(selection.subject.type)}:${selection.subject.id}`, + selection.access + ]) + ); + + const existingPermissions = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject && + p.id && + (p.subject_type === "person" || + p.subject_type === "team" || + p.subject_type === "group" || + p.subject_type === "role") && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + const existingByKey = new Map(); + + for (const permission of existingPermissions) { + const key = `${permission.subject_type}:${permission.subject}`; + const list = existingByKey.get(key) ?? []; + list.push(permission); + existingByKey.set(key, list); + } + + const resourceSelector = [ + { name: resource.name, namespace: resource.namespace } + ]; + + const payloadsToAdd = normalizedSelections + .map((selection) => { + const subjectType = mapSubjectType(selection.subject.type); + const key = `${subjectType}:${selection.subject.id}`; + if (existingByKey.has(key)) { + return null; + } + return { + object_selector: { [objectSelectorKey]: resourceSelector }, + action: "mcp:run", + subject: selection.subject.id, + subject_type: subjectType, + deny: selection.access === "deny", + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + }; + }) + .filter((payload): payload is NonNullable => !!payload) + .sort((a, b) => + `${a.subject_type}:${a.subject}`.localeCompare( + `${b.subject_type}:${b.subject}` + ) + ); + + const updatePayloads = Array.from(existingByKey.entries()) + .map(([key, permissionsForSubject]) => { + const desiredAccess = desiredAccessByKey.get(key); + + if (!desiredAccess) { + return null; + } + + const [primary, ...duplicates] = permissionsForSubject; + if (!primary?.id) { + return null; + } + + const shouldDeny = desiredAccess === "deny"; + const requiresUpdate = + primary.deny === true ? !shouldDeny : shouldDeny; + + return { + id: primary.id, + deny: shouldDeny, + requiresUpdate, + duplicateIds: duplicates + .map((permission) => permission.id) + .filter((id): id is string => !!id) + }; + }) + .filter((entry): entry is NonNullable => !!entry) + .sort((a, b) => a.id.localeCompare(b.id)); + + const deleteIds = [ + ...existingPermissions + .filter((permission) => { + const key = `${permission.subject_type}:${permission.subject}`; + return !desiredAccessByKey.has(key); + }) + .map((permission) => permission.id!), + ...updatePayloads.flatMap((entry) => entry.duplicateIds) + ].sort((a, b) => a.localeCompare(b)); + + const addedPermissionIds: string[] = []; + + try { + for (const payload of payloadsToAdd) { + const created = await addPermission(payload as any); + if (created?.data?.id) { + addedPermissionIds.push(created.data.id); + } + } + + for (const payload of updatePayloads) { + if (!payload.requiresUpdate) { + continue; + } + + await updatePermission({ + id: payload.id, + deny: payload.deny + } as any); + } + + for (const id of deleteIds) { + await deletePermission(id); + } + } catch (error) { + if (addedPermissionIds.length > 0) { + await Promise.allSettled( + addedPermissionIds.map((id) => deletePermission(id)) + ); + } + throw error; + } + + return { + added: payloadsToAdd.length, + updated: updatePayloads.filter((entry) => entry.requiresUpdate).length, + removed: deleteIds.length + }; + }, + onSuccess: ({ added, updated, removed }) => { + if (added === 0 && updated === 0 && removed === 0) { + toastSuccess("No permission changes"); + } else { + toastSuccess( + `Updated permissions: +${added} / ~${updated} / -${removed}` + ); + } + setSelectedResourceId(null); + }, + onError: (error) => { + toastError(error as any); + }, + onSettled: () => { + refetchPermissions(); + } + }); + + // ── Wrapped handlers (manage mutating-id state internally) ────────── + + const setGlobalOverride = ( + resource: TResource, + override: "allow" | "none" | "deny" + ) => { + setMutatingResourceId(resource.id); + setGlobalOverrideMutation( + { resource, override }, + { + onSettled: () => { + setMutatingResourceId((cur) => (cur === resource.id ? null : cur)); + } + } + ); + }; + + const allowSelectiveAccess = async ( + resource: TResource, + subjects: PermissionSubject[] | SubjectAccessSelection[] + ) => { + await allowSelectiveAccessMutation({ resource, subjects }); + }; + + const { + mutateAsync: setSelectiveSubjectAccessMutation, + isLoading: isSettingSelectiveSubjectAccess + } = useMutation({ + mutationFn: async ({ + resource, + subject, + access + }: { + resource: TResource; + subject: PermissionSubject; + access: "allow" | "deny" | "default"; + }) => { + const latestPermissions = await fetchPermissions(); + const subjectType = mapSubjectType(subject.type); + + const existingPermissions = latestPermissions.filter( + (p) => + p.action === "mcp:run" && + p.subject === subject.id && + p.subject_type === subjectType && + p.id && + p.source === MCP_SETTINGS_PERMISSION_SOURCE && + permissionMatchesResource(p, resource, getRefs) + ); + + if (access === "default") { + await Promise.all( + existingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const shouldDeny = access === "deny"; + const [primary, ...duplicates] = existingPermissions; + + if (!primary) { + await addPermission({ + object_selector: { + [objectSelectorKey]: [ + { name: resource.name, namespace: resource.namespace } + ] + }, + action: "mcp:run", + subject: subject.id, + subject_type: subjectType, + deny: shouldDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + } else if (primary.deny !== shouldDeny) { + await updatePermission({ + id: primary.id, + deny: shouldDeny + } as any); + } + + if (duplicates.length > 0) { + await Promise.all( + duplicates + .map((permission) => permission.id) + .filter((id): id is string => !!id) + .map((id) => deletePermission(id)) + ); + } + }, + onError: (error) => { + toastError(error as any); + }, + onSettled: () => { + refetchPermissions(); + } + }); + + const setSelectiveSubjectAccess = async ( + resource: TResource, + subject: PermissionSubject, + access: "allow" | "deny" | "default" + ) => { + setMutatingSubjectId(subject.id); + try { + await setSelectiveSubjectAccessMutation({ resource, subject, access }); + } finally { + setMutatingSubjectId((cur) => (cur === subject.id ? null : cur)); + } + }; + + // ── Loading state ─────────────────────────────────────────────────── + + const loading = + isResourcesLoading || + isPermissionsLoading || + isResourcesRefetching || + isPermissionsRefetching || + isUpdatingGlobalOverride || + isAllowingSelective || + isSettingSelectiveSubjectAccess; + + const isInitialLoading = + (isResourcesLoading || isPermissionsLoading) && resources.length === 0; + + return { + permissionsByResource, + globalOverrideByResource, + selectedResource, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + allowSelectiveAccess, + isAllowingSelective, + setSelectiveSubjectAccess, + mutatingSubjectId, + isSettingSelectiveSubjectAccess, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: () => { + refetchResources(); + refetchPermissions(); + } + }; +} diff --git a/src/pages/Settings/PermissionsPage.tsx b/src/pages/Settings/PermissionsPage.tsx index 0e4fda18dd..5d00eba87f 100644 --- a/src/pages/Settings/PermissionsPage.tsx +++ b/src/pages/Settings/PermissionsPage.tsx @@ -1,19 +1,14 @@ import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; import AddPermissionButton from "@flanksource-ui/components/Permissions/ManagePermissions/Forms/AddPermissionButton"; +import PermissionsTabsLinks from "@flanksource-ui/components/Permissions/PermissionsTabsLinks"; import PermissionsView, { permissionsActionsList } from "@flanksource-ui/components/Permissions/PermissionsView"; +import { ReactSelectDropdown } from "@flanksource-ui/components/ReactSelectDropdown"; import { tables } from "@flanksource-ui/context/UserAccessContext/permissions"; -import { - BreadcrumbNav, - BreadcrumbRoot -} from "@flanksource-ui/ui/BreadcrumbNav"; -import { Head } from "@flanksource-ui/ui/Head"; -import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; -import { useMemo, useRef, useState } from "react"; import { parseTristateKeyState } from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { ReactSelectDropdown } from "@flanksource-ui/components/ReactSelectDropdown"; export function PermissionsPage() { const [isLoading, setIsLoading] = useState(false); @@ -63,68 +58,53 @@ export function PermissionsPage() { ); return ( - <> - - - Permissions - , - - - - ]} + refetchFunctionRef.current?.()} + headerAction={ + + + + } + > +
+
+ { + const nextParams = new URLSearchParams(searchParams); + if (!value || value === "all") { + nextParams.delete("action"); + } else { + nextParams.set("action", value); + } + setSearchParams(nextParams); + }} + className="min-w-[180px]" + dropDownClassNames="w-[260px] left-0" + hideControlBorder + prefix={Action:} + /> +
+
+ { + refetchFunctionRef.current = refetch; + }} /> - } - onRefresh={() => refetchFunctionRef.current?.()} - contentClass="p-0 h-full" - loading={isLoading} - > -
-
- { - const nextParams = new URLSearchParams(searchParams); - if (!value || value === "all") { - nextParams.delete("action"); - } else { - nextParams.set("action", value); - } - setSearchParams(nextParams); - }} - className="min-w-[180px]" - dropDownClassNames="w-[260px] left-0" - hideControlBorder - prefix={Action:} - /> -
-
- { - refetchFunctionRef.current = refetch; - }} - /> -
- - +
+ ); } diff --git a/src/pages/Settings/PermissionsSubjectsPage.tsx b/src/pages/Settings/PermissionsSubjectsPage.tsx new file mode 100644 index 0000000000..d68e3765b4 --- /dev/null +++ b/src/pages/Settings/PermissionsSubjectsPage.tsx @@ -0,0 +1,126 @@ +import { fetchAllPermissionSubjects } from "@flanksource-ui/api/services/permissions"; +import PermissionSubjectPanel, { + PermissionSubjectGroup +} from "@flanksource-ui/components/Permissions/PermissionSubjectPanel"; +import PermissionsTabsLinks from "@flanksource-ui/components/Permissions/PermissionsTabsLinks"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const SUBJECT_TYPE_ORDER = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +} as const; + +export function PermissionsSubjectsPage() { + const [subjectSearch, setSubjectSearch] = useState(""); + const [selectedSubjectId, setSelectedSubjectId] = useState( + null + ); + + const debouncedSearch = useDebouncedValue(subjectSearch, 200) ?? ""; + + const { + data: subjects = [], + isLoading, + isRefetching, + refetch + } = useQuery({ + queryKey: ["permissions", "subjects", "all"], + queryFn: fetchAllPermissionSubjects + }); + + const sortedSubjects = useMemo(() => { + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + return subjects + .filter((subject) => { + if (!loweredSearch) { + return true; + } + + return subject.name.toLowerCase().includes(loweredSearch); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + }, [debouncedSearch, subjects]); + + const groupedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of sortedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()).map(([type, list]) => ({ + type, + list + })); + }, [sortedSubjects]); + + useEffect(() => { + if ( + selectedSubjectId && + sortedSubjects.some((subject) => subject.id === selectedSubjectId) + ) { + return; + } + + setSelectedSubjectId(sortedSubjects[0]?.id ?? null); + }, [selectedSubjectId, sortedSubjects]); + + const selectedSubject = useMemo( + () => + sortedSubjects.find((subject) => subject.id === selectedSubjectId) ?? + null, + [selectedSubjectId, sortedSubjects] + ); + + return ( + refetch()} + > +
+
+

Subjects

+

+ Browse permission subjects. Subject details and actions will be + added here. +

+
+ +
+ + +
+
+ {selectedSubject + ? `Selected subject: ${selectedSubject.name}` + : "Select a subject."} +
+
+
+
+
+ ); +} diff --git a/src/pages/Settings/mcp/McpCheckAccessPage.tsx b/src/pages/Settings/mcp/McpCheckAccessPage.tsx new file mode 100644 index 0000000000..57ba435ca9 --- /dev/null +++ b/src/pages/Settings/mcp/McpCheckAccessPage.tsx @@ -0,0 +1,50 @@ +import { SubjectAccessReviewAction } from "@flanksource-ui/api/services/rbac"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import { + PermissionAccessCheckConfig, + PermissionAccessCheckForm +} from "@flanksource-ui/components/Permissions/PermissionAccessCheckModal"; + +const mcpAccessCheckActions: SubjectAccessReviewAction[] = [ + "mcp:run", + "mcp:use" +]; + +const mcpAccessCheckConfig: PermissionAccessCheckConfig = { + actions: mcpAccessCheckActions, + title: "MCP Access Check", + description: + "Select a subject and action to check MCP access. Resource is required for mcp:run and fixed to MCP for mcp:use.", + allowedResourceTypes: ["playbook", "view"], + hideResourceForActions: ["mcp:use"], + resourceOverrideByAction: { + "mcp:use": { + global: "mcp" + } + } +}; + +export default function McpCheckAccessPage() { + return ( + +
+
+

Check Access

+

+ Permissions granted from the MCP settings page are only one part of + the overall access model. Additional permissions, created elsewhere + in the UI or from Kubernetes resources, may override or supersede + them. +

+
+

+ This check evaluates the final effective access across all + permissions. +

+
+ + +
+
+ ); +} diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx new file mode 100644 index 0000000000..87fce8321b --- /dev/null +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -0,0 +1,286 @@ +import { + addPermission, + deletePermission, + fetchAllPermissionSubjects, + fetchMcpUserPermissions, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { useTokensListQuery } from "@flanksource-ui/api/query-hooks/useTokensQuery"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import UserList from "@flanksource-ui/components/MCP/UserList"; +import TokensTable from "@flanksource-ui/components/Tokens/List/TokensTable"; +import { toastError } from "@flanksource-ui/components/Toast/toast"; +import SetupMcpModal from "@flanksource-ui/components/Users/SetupMcpModal"; +import { useUser } from "@flanksource-ui/context"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { AiFillPlusCircle } from "react-icons/ai"; + +const MCP_OBJECT = "mcp"; +const MCP_ACTION = "mcp:use"; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +function isMcpUserAccessPermission(permission: PermissionsSummary) { + return ( + permission.action === MCP_ACTION && + permission.object === MCP_OBJECT && + !!permission.subject && + !!permission.id && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); +} + +function mapPermissionSubjectType(type: PermissionSubject["type"]) { + if (type === "permission_subject_group" || type === "role") { + return "group" as const; + } + + if (type === "access_token_person") { + return "person" as const; + } + + return type; +} + +export default function McpOverviewPage() { + const { user } = useUser(); + const [mutatingSubjectId, setMutatingSubjectId] = useState( + null + ); + const [isSetupMcpModalOpen, setIsSetupMcpModalOpen] = useState(false); + + const { + data: subjects = [], + isLoading: isUsersLoading, + refetch: refetchUsers, + isRefetching: isUsersRefetching + } = useQuery({ + queryKey: ["mcp", "permission-subjects", "all"], + queryFn: fetchAllPermissionSubjects + }); + + const { + data: userPermissions = [], + isLoading: isPermissionsLoading, + refetch: refetchPermissions, + isRefetching: isPermissionsRefetching + } = useQuery({ + queryKey: ["mcp", "users", "permissions"], + queryFn: fetchMcpUserPermissions + }); + + const permissionsByUser = useMemo(() => { + const map = new Map(); + + for (const permission of userPermissions) { + if (!isMcpUserAccessPermission(permission)) { + continue; + } + + const subjectId = permission.subject!; + const current = map.get(subjectId) ?? []; + current.push(permission); + map.set(subjectId, current); + } + + return map; + }, [userPermissions]); + + const groupedSubjects = useMemo(() => { + const seen = new Set(); + const grouped = new Map(); + + for (const subject of subjects) { + if (subject.type === "access_token_person") { + continue; + } + + if (seen.has(subject.id)) { + continue; + } + seen.add(subject.id); + + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()) + .sort((a, b) => SUBJECT_TYPE_ORDER[a[0]] - SUBJECT_TYPE_ORDER[b[0]]) + .map(([type, groupedItems]) => ({ + type, + subjects: groupedItems.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ) + })); + }, [subjects]); + + const { mutate: setUserAccess, isLoading: isUpdatingUserAccess } = + useMutation({ + mutationFn: async ({ + subjectId, + subjectType, + access + }: { + subjectId: string; + subjectType: PermissionSubject["type"]; + access: "deny" | "default" | "allow"; + }) => { + // Re-fetch to avoid acting on stale data from the render closure + const latestPermissions = await fetchMcpUserPermissions(); + const existingPermissions = latestPermissions + .filter(isMcpUserAccessPermission) + .filter( + (permission) => + permission.subject === subjectId && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE + ); + + if (access === "default") { + await Promise.all( + existingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const targetDeny = access === "deny"; + const primaryPermission = existingPermissions[0]; + const duplicatePermissionIds = existingPermissions + .slice(1) + .map((permission) => permission.id!); + + if (!primaryPermission) { + if (!user?.id) { + throw new Error("User must be logged in to create permissions"); + } + + await addPermission({ + object: MCP_OBJECT, + action: MCP_ACTION, + subject: subjectId, + subject_type: mapPermissionSubjectType(subjectType), + deny: targetDeny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user.id + } as any); + + return; + } + + await Promise.all([ + ...(primaryPermission.deny !== targetDeny + ? [ + updatePermission({ + id: primaryPermission.id, + deny: targetDeny + } as any) + ] + : []), + ...duplicatePermissionIds.map((id) => deletePermission(id)) + ]); + }, + onSettled: () => { + refetchPermissions(); + }, + onError: (error) => { + toastError(error as any); + } + }); + + const { + data: tokens = [], + isLoading: isTokensLoading, + refetch: refetchTokens, + isRefetching: isTokensRefetching + } = useTokensListQuery({ + keepPreviousData: true, + staleTime: 0, + cacheTime: 0 + }); + + const loading = + isUsersLoading || + isPermissionsLoading || + isUsersRefetching || + isPermissionsRefetching || + isUpdatingUserAccess; + + const isInitialLoading = + (isUsersLoading || isPermissionsLoading) && subjects.length === 0; + + return ( + setIsSetupMcpModalOpen(true)} + > + + + } + onRefresh={() => { + refetchUsers(); + refetchPermissions(); + refetchTokens(); + }} + > + { + setMutatingSubjectId(subject.id); + setUserAccess( + { subjectId: subject.id, subjectType: subject.type, access }, + { + onSettled: () => { + setMutatingSubjectId((current) => + current === subject.id ? null : current + ); + } + } + ); + }} + /> + +
+
+ MCP access tokens +
+ +
+ + setIsSetupMcpModalOpen(false)} + /> +
+ ); +} diff --git a/src/pages/Settings/mcp/McpPlaybooksPage.tsx b/src/pages/Settings/mcp/McpPlaybooksPage.tsx new file mode 100644 index 0000000000..84b9fe03bc --- /dev/null +++ b/src/pages/Settings/mcp/McpPlaybooksPage.tsx @@ -0,0 +1,263 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { fetchMcpRunPermissions } from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorPanel, { + SubjectAccess +} from "@flanksource-ui/components/Permissions/SubjectSelectorPanel"; +import { Input } from "@flanksource-ui/components/ui/input"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useMcpResourcePermissions } from "@flanksource-ui/lib/permissions/useMcpResourcePermissions"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const getPlaybookRefs = (permission: PermissionsSummary) => + permission.object_selector?.playbooks ?? []; + +export default function McpPlaybooksPage() { + const { + data: playbooks = [], + isLoading, + refetch, + isRefetching + } = useQuery({ + queryKey: ["mcp", "playbooks", "all"], + queryFn: getAllPlaybookNames + }); + + const { + globalOverrideByResource, + selectedResource: selectedPlaybook, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + setSelectiveSubjectAccess, + isSettingSelectiveSubjectAccess, + mutatingSubjectId, + allowSelectiveAccess, + isAllowingSelective, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: refetchAll + } = useMcpResourcePermissions({ + resources: playbooks, + isResourcesLoading: isLoading, + isResourcesRefetching: isRefetching, + refetchResources: refetch, + permissionsQueryKey: ["mcp", "playbooks", "permissions"], + fetchPermissions: fetchMcpRunPermissions, + getRefs: getPlaybookRefs, + objectSelectorKey: "playbooks" + }); + + const [isSubjectPanelSwitching, setIsSubjectPanelSwitching] = useState(false); + const [playbookSearch, setPlaybookSearch] = useState(""); + + const debouncedSearch = useDebouncedValue(playbookSearch, 200) ?? ""; + + useEffect(() => { + if (!selectedPlaybook) { + setIsSubjectPanelSwitching(false); + return; + } + + setIsSubjectPanelSwitching(true); + const timer = setTimeout(() => { + setIsSubjectPanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedPlaybook?.id]); + + const groupedPlaybooks = useMemo(() => { + const grouped = new Map(); + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + for (const playbook of playbooks) { + const displayName = playbook.title || playbook.name; + const haystack = + `${displayName} ${playbook.namespace || ""} ${playbook.category || ""}`.toLowerCase(); + + if (loweredSearch && !haystack.includes(loweredSearch)) { + continue; + } + + const category = playbook.category?.trim() || "Other"; + const categoryPlaybooks = grouped.get(category) ?? []; + categoryPlaybooks.push(playbook); + grouped.set(category, categoryPlaybooks); + } + + return Array.from(grouped.entries()) + .sort(([a], [b]) => { + if (a === "Other") { + return 1; + } + if (b === "Other") { + return -1; + } + + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }) + .map(([category, categoryPlaybooks]) => ({ + category, + playbooks: categoryPlaybooks.sort((a, b) => + (a.title || a.name).localeCompare(b.title || b.name, undefined, { + sensitivity: "base" + }) + ) + })); + }, [debouncedSearch, playbooks]); + + const visiblePlaybooks = useMemo( + () => groupedPlaybooks.flatMap((group) => group.playbooks), + [groupedPlaybooks] + ); + + useEffect(() => { + if ( + selectedPlaybook?.id && + visiblePlaybooks.some((playbook) => playbook.id === selectedPlaybook.id) + ) { + return; + } + + setSelectedResourceId(visiblePlaybooks[0]?.id ?? null); + }, [selectedPlaybook?.id, setSelectedResourceId, visiblePlaybooks]); + + return ( + +
+
+

+ Playbook permissions +

+

+ Control which playbooks MCP clients can invoke through this gateway. +

+
+ +
+
+ setPlaybookSearch(event.target.value)} + /> + +
+ {groupedPlaybooks.length === 0 ? ( +
+ No playbooks found +
+ ) : ( + groupedPlaybooks.map((group) => ( +
+
+ {group.category} +
+ + {group.playbooks.map((playbook) => { + const isActive = selectedPlaybook?.id === playbook.id; + + return ( + + ); + })} +
+ )) + )} +
+
+ +
+ {selectedPlaybook ? ( +
+ + setGlobalOverride( + selectedPlaybook, + access === "allow" + ? "allow" + : access === "deny" + ? "deny" + : "none" + ) + } + onSetSubjectAccess={(subject, access) => + setSelectiveSubjectAccess(selectedPlaybook, subject, access) + } + onSetManySubjectAccess={(selections) => + allowSelectiveAccess(selectedPlaybook, selections) + } + /> + + {isSubjectPanelSwitching ? ( +
+ ) : null} +
+ ) : ( +
+ Select a playbook row to manage custom subject access. +
+ )} +
+
+
+ + ); +} diff --git a/src/pages/Settings/mcp/McpSubjectAccessPage.tsx b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx new file mode 100644 index 0000000000..ddeeda5937 --- /dev/null +++ b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx @@ -0,0 +1,568 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + addPermission, + deletePermission, + fetchAllPermissionSubjects, + fetchMcpRunPermissions, + MCP_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { + fetchEffectiveSubjectResourceAccess, + EffectiveSubjectResourceAccessResult +} from "@flanksource-ui/api/services/rbac"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import PermissionSubjectPanel from "@flanksource-ui/components/Permissions/PermissionSubjectPanel"; +import ResourceSelectorPanel, { + McpSubjectResource, + ResourceAccess +} from "@flanksource-ui/components/Permissions/ResourceSelectorPanel"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const SUBJECT_TYPE_ORDER: Record = { + role: 0, + permission_subject_group: 1, + team: 2, + person: 3, + access_token_person: 4 +}; + +function getPermissionRefs( + permission: PermissionsSummary, + kind: "playbook" | "view" +) { + return kind === "playbook" + ? (permission.object_selector?.playbooks ?? []) + : (permission.object_selector?.views ?? []); +} + +function permissionMatchesResource( + permission: PermissionsSummary, + resource: McpSubjectResource +) { + const refs = getPermissionRefs(permission, resource.kind); + + return refs.some((ref) => { + if (!ref?.name || ref.name === "*") { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; + }); +} + +export default function McpSubjectAccessPage() { + const { user } = useUser(); + const [subjectSearch, setSubjectSearch] = useState(""); + const [selectedSubjectId, setSelectedSubjectId] = useState( + null + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [mutatingResourceIds, setMutatingResourceIds] = useState< + Record + >({}); + const [isResourcePanelSwitching, setIsResourcePanelSwitching] = + useState(false); + const [ + hasTriggeredEffectiveAccessCheck, + setHasTriggeredEffectiveAccessCheck + ] = useState(false); + + const debouncedSearch = useDebouncedValue(subjectSearch, 200) ?? ""; + + const { + data: subjects = [], + isLoading: isSubjectsLoading, + isRefetching: isSubjectsRefetching, + refetch: refetchSubjects + } = useQuery({ + queryKey: ["mcp", "subject-access", "subjects"], + queryFn: fetchAllPermissionSubjects + }); + + const { + data: playbooks = [], + isLoading: isPlaybooksLoading, + isRefetching: isPlaybooksRefetching, + refetch: refetchPlaybooks + } = useQuery({ + queryKey: ["mcp", "subject-access", "playbooks"], + queryFn: getAllPlaybookNames + }); + + const { + data: viewsResponse, + isLoading: isViewsLoading, + isRefetching: isViewsRefetching, + refetch: refetchViews + } = useQuery({ + queryKey: ["mcp", "subject-access", "views"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000) + }); + + const { + data: permissions = [], + isLoading: isPermissionsLoading, + isRefetching: isPermissionsRefetching, + refetch: refetchPermissions + } = useQuery({ + queryKey: ["mcp", "subject-access", "permissions"], + queryFn: fetchMcpRunPermissions + }); + + const views = useMemo(() => viewsResponse?.data ?? [], [viewsResponse?.data]); + + const sortedSubjects = useMemo(() => { + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + return subjects + .filter((subject) => subject.type !== "access_token_person") + .filter((subject) => { + if (!loweredSearch) { + return true; + } + + return subject.name.toLowerCase().includes(loweredSearch); + }) + .sort((a, b) => { + const typeOrder = + SUBJECT_TYPE_ORDER[a.type] - SUBJECT_TYPE_ORDER[b.type]; + if (typeOrder !== 0) { + return typeOrder; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + }, [debouncedSearch, subjects]); + + const groupedSubjects = useMemo(() => { + const grouped = new Map(); + + for (const subject of sortedSubjects) { + const list = grouped.get(subject.type) ?? []; + list.push(subject); + grouped.set(subject.type, list); + } + + return Array.from(grouped.entries()).map(([type, list]) => ({ + type, + list + })); + }, [sortedSubjects]); + + useEffect(() => { + if ( + selectedSubjectId && + sortedSubjects.some((subject) => subject.id === selectedSubjectId) + ) { + return; + } + + setSelectedSubjectId(sortedSubjects[0]?.id ?? null); + }, [selectedSubjectId, sortedSubjects]); + + const selectedSubject = useMemo( + () => + sortedSubjects.find((subject) => subject.id === selectedSubjectId) ?? + null, + [selectedSubjectId, sortedSubjects] + ); + + useEffect(() => { + if (!selectedSubject) { + setIsResourcePanelSwitching(false); + return; + } + + setIsResourcePanelSwitching(true); + const timer = setTimeout(() => { + setIsResourcePanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedSubject?.id]); + + const resources = useMemo(() => { + const playbookResources = playbooks + .map((playbook) => ({ + id: playbook.id, + kind: "playbook" as const, + name: playbook.name, + displayName: playbook.title || playbook.name, + namespace: playbook.namespace, + icon: playbook.icon || "playbook", + subtitle: playbook.namespace + ? `${playbook.namespace} · playbook` + : "playbook" + })) + .sort((a, b) => + (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { + sensitivity: "base" + } + ) + ); + + const viewResources = views + .map((view) => ({ + id: view.id, + kind: "view" as const, + name: view.name, + displayName: view.spec?.title || view.name, + namespace: view.namespace, + icon: view.spec?.icon || "workflow", + subtitle: view.namespace ? `${view.namespace} · view` : "view" + })) + .sort((a, b) => + (a.displayName || a.name).localeCompare( + b.displayName || b.name, + undefined, + { + sensitivity: "base" + } + ) + ); + + return [...playbookResources, ...viewResources]; + }, [playbooks, views]); + + const { + data: effectiveAccessResponse, + isFetching: isCheckingEffectiveAccess, + refetch: refetchEffectiveAccess + } = useQuery({ + queryKey: [ + "mcp", + "subject-access", + "effective-access", + selectedSubject?.id ?? "none", + resources.length + ], + enabled: false, + queryFn: async () => { + if (!selectedSubject) { + return { + subject: "", + action: "mcp:run" as const, + results: [] as EffectiveSubjectResourceAccessResult[] + }; + } + + return fetchEffectiveSubjectResourceAccess({ + subject: selectedSubject.id, + action: "mcp:run", + resources: resources.map((resource) => ({ + id: resource.id, + type: resource.kind + })) + }); + } + }); + + const effectiveAccessByResourceKey = useMemo(() => { + const map: Record = {}; + + for (const result of effectiveAccessResponse?.results ?? []) { + map[`${result.resourceType}:${result.resourceId}`] = result.allowed; + } + + return map; + }, [effectiveAccessResponse?.results]); + + const hasEffectiveAccessResults = + hasTriggeredEffectiveAccessCheck && + (effectiveAccessResponse?.results?.length ?? 0) > 0; + + useEffect(() => { + setHasTriggeredEffectiveAccessCheck(false); + }, [selectedSubject?.id]); + + const loading = + isSubjectsLoading || + isPlaybooksLoading || + isViewsLoading || + isPermissionsLoading || + isSubjectsRefetching || + isPlaybooksRefetching || + isViewsRefetching || + isPermissionsRefetching || + isSubmitting; + + const isInitialLoading = + (isSubjectsLoading || + isPlaybooksLoading || + isViewsLoading || + isPermissionsLoading) && + resources.length === 0; + + const setResourceAccess = async ( + subject: PermissionSubject, + targetResource: McpSubjectResource, + access: ResourceAccess, + latestPermissions?: PermissionsSummary[] + ) => { + const currentPermissions = + latestPermissions ?? (await fetchMcpRunPermissions()); + const subjectType = mapSubjectType(subject.type); + + const matchingPermissions = currentPermissions.filter( + (permission) => + permission.action === "mcp:run" && + permission.source === MCP_SETTINGS_PERMISSION_SOURCE && + permission.subject === subject.id && + permission.subject_type === subjectType && + permission.id && + permissionMatchesResource(permission, targetResource) + ); + + if (access === "default") { + await Promise.all( + matchingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + return; + } + + const deny = access === "deny"; + const [primaryPermission, ...duplicates] = matchingPermissions; + + if (!primaryPermission) { + await addPermission({ + action: "mcp:run", + object_selector: { + [targetResource.kind === "playbook" ? "playbooks" : "views"]: [ + { name: targetResource.name, namespace: targetResource.namespace } + ] + }, + subject: subject.id, + subject_type: subjectType, + deny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + return; + } + + await Promise.all([ + ...(primaryPermission.deny === deny + ? [] + : [ + updatePermission({ + id: primaryPermission.id, + deny + } as any) + ]), + ...duplicates.map((permission) => deletePermission(permission.id!)) + ]); + }; + + const applyAccess = async ( + targetResources: McpSubjectResource[], + access: ResourceAccess + ) => { + if (!selectedSubject || targetResources.length === 0) { + return; + } + + const subjectType = mapSubjectType(selectedSubject.type); + const targetKinds = [ + ...new Set(targetResources.map((resource) => resource.kind)) + ]; + + setIsSubmitting(true); + setMutatingResourceIds( + Object.fromEntries(targetResources.map((resource) => [resource.id, true])) + ); + + try { + const latestPermissions = await fetchMcpRunPermissions(); + + if (targetResources.length > 1) { + for (const kind of targetKinds) { + const selectorKey = kind === "playbook" ? "playbooks" : "views"; + + const existingForKind = latestPermissions.filter((permission) => { + if ( + permission.action !== "mcp:run" || + permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + permission.subject !== selectedSubject.id || + permission.subject_type !== subjectType || + !permission.id + ) { + return false; + } + + return getPermissionRefs(permission, kind).length > 0; + }); + + if (access === "default") { + await Promise.all( + existingForKind.map((permission) => + deletePermission(permission.id!) + ) + ); + continue; + } + + const deny = access === "deny"; + const wildcardPermissions = existingForKind.filter((permission) => + getPermissionRefs(permission, kind).some( + (ref) => ref?.name === "*" && !ref?.namespace + ) + ); + + const [primaryWildcard, ...duplicateWildcards] = wildcardPermissions; + + if (!primaryWildcard) { + await addPermission({ + action: "mcp:run", + object_selector: { + [selectorKey]: [{ name: "*" }] + }, + subject: selectedSubject.id, + subject_type: subjectType, + deny, + source: MCP_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + } else if (primaryWildcard.deny !== deny) { + await updatePermission({ + id: primaryWildcard.id, + deny + } as any); + } + + const wildcardIdsToKeep = new Set( + primaryWildcard?.id ? [primaryWildcard.id] : [] + ); + + const permissionIdsToDelete = [ + ...duplicateWildcards.map((permission) => permission.id!), + ...existingForKind + .filter((permission) => !wildcardIdsToKeep.has(permission.id!)) + .filter( + (permission) => + !wildcardPermissions.some( + (wildcard) => wildcard.id === permission.id + ) + ) + .map((permission) => permission.id!) + ]; + + if (permissionIdsToDelete.length > 0) { + await Promise.all( + permissionIdsToDelete.map((id) => deletePermission(id)) + ); + } + } + + toastSuccess("Updated subject access"); + } else { + await setResourceAccess( + selectedSubject, + targetResources[0], + access, + latestPermissions + ); + toastSuccess("Updated subject access"); + } + } catch (error) { + toastError(error as any); + } finally { + setMutatingResourceIds({}); + setIsSubmitting(false); + refetchPermissions(); + } + }; + + return ( + { + refetchSubjects(); + refetchPlaybooks(); + refetchViews(); + refetchPermissions(); + }} + > +
+
+

+ Subject access +

+

+ See all playbooks and views a specific user, role, or group can + access through this gateway. +

+
+ +
+ + +
+ {selectedSubject ? ( +
+ { + setHasTriggeredEffectiveAccessCheck(true); + refetchEffectiveAccess(); + }} + onSetResourceAccess={(resource, access) => + applyAccess([resource], access) + } + onSetManyResourceAccess={applyAccess} + /> + + {isResourcePanelSwitching ? ( +
+ ) : null} +
+ ) : ( +
+ Select a subject to inspect MCP resource access. +
+ )} +
+
+
+ + ); +} diff --git a/src/pages/Settings/mcp/McpViewsPage.tsx b/src/pages/Settings/mcp/McpViewsPage.tsx new file mode 100644 index 0000000000..2bf0bef36f --- /dev/null +++ b/src/pages/Settings/mcp/McpViewsPage.tsx @@ -0,0 +1,262 @@ +import { fetchMcpRunPermissions } from "@flanksource-ui/api/services/permissions"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import McpTabsLinks from "@flanksource-ui/components/MCP/McpTabsLinks"; +import SubjectSelectorPanel, { + SubjectAccess +} from "@flanksource-ui/components/Permissions/SubjectSelectorPanel"; +import { Input } from "@flanksource-ui/components/ui/input"; +import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; +import { useMcpResourcePermissions } from "@flanksource-ui/lib/permissions/useMcpResourcePermissions"; +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +const getViewRefs = (permission: PermissionsSummary) => + permission.object_selector?.views ?? []; + +export default function McpViewsPage() { + const { + isLoading, + data: viewsResponse, + refetch, + isRefetching + } = useQuery({ + queryKey: ["mcp", "views", "all"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000) + }); + + const views = useMemo(() => viewsResponse?.data ?? [], [viewsResponse?.data]); + + const { + globalOverrideByResource, + selectedResource: selectedView, + setSelectedResourceId, + mutatingResourceId, + setGlobalOverride, + setSelectiveSubjectAccess, + isSettingSelectiveSubjectAccess, + mutatingSubjectId, + allowSelectiveAccess, + isAllowingSelective, + loading, + isInitialLoading, + preselectedSubjectAccess, + refetch: refetchAll + } = useMcpResourcePermissions({ + resources: views, + isResourcesLoading: isLoading, + isResourcesRefetching: isRefetching, + refetchResources: refetch, + permissionsQueryKey: ["mcp", "views", "permissions"], + fetchPermissions: fetchMcpRunPermissions, + getRefs: getViewRefs, + objectSelectorKey: "views" + }); + + const [isSubjectPanelSwitching, setIsSubjectPanelSwitching] = useState(false); + const [viewSearch, setViewSearch] = useState(""); + + const debouncedSearch = useDebouncedValue(viewSearch, 200) ?? ""; + + useEffect(() => { + if (!selectedView) { + setIsSubjectPanelSwitching(false); + return; + } + + // SubjectSelectorPanel remounts when the selected view changes (key={selectedView.id}). + // Keep this overlay on briefly so the switch feels intentional instead of a flicker. + setIsSubjectPanelSwitching(true); + const timer = setTimeout(() => { + setIsSubjectPanelSwitching(false); + }, 220); + + return () => { + clearTimeout(timer); + }; + }, [selectedView?.id]); + + const groupedViews = useMemo(() => { + const grouped = new Map(); + const loweredSearch = debouncedSearch.trim().toLowerCase(); + + for (const view of views) { + const displayName = view.spec?.title || view.name; + const group = view.namespace?.trim() || "Global"; + const haystack = `${displayName} ${view.namespace || ""}`.toLowerCase(); + + if (loweredSearch && !haystack.includes(loweredSearch)) { + continue; + } + + const list = grouped.get(group) ?? []; + list.push(view); + grouped.set(group, list); + } + + return Array.from(grouped.entries()) + .sort(([a], [b]) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ) + .map(([group, groupedItems]) => ({ + group, + views: groupedItems.sort((a, b) => + (a.spec?.title || a.name).localeCompare( + b.spec?.title || b.name, + undefined, + { + sensitivity: "base" + } + ) + ) + })); + }, [debouncedSearch, views]); + + const visibleViews = useMemo( + () => groupedViews.flatMap((group) => group.views), + [groupedViews] + ); + + useEffect(() => { + if ( + selectedView?.id && + visibleViews.some((view) => view.id === selectedView.id) + ) { + return; + } + + setSelectedResourceId(visibleViews[0]?.id ?? null); + }, [selectedView?.id, setSelectedResourceId, visibleViews]); + + return ( + +
+
+

+ View permissions +

+

+ Control which views MCP clients can invoke through this gateway. +

+
+ +
+
+ setViewSearch(event.target.value)} + /> + +
+ {groupedViews.length === 0 ? ( +
+ No views found +
+ ) : ( + groupedViews.map((group) => ( +
+
+ {group.group} +
+ + {group.views.map((view) => { + const isActive = selectedView?.id === view.id; + + return ( + + ); + })} +
+ )) + )} +
+
+ + {/* Keep a stable right-column width/height for both states so layout doesn't jump. */} +
+ {selectedView ? ( +
+ + setGlobalOverride( + selectedView, + access === "allow" + ? "allow" + : access === "deny" + ? "deny" + : "none" + ) + } + onSetSubjectAccess={(subject, access) => + setSelectiveSubjectAccess(selectedView, subject, access) + } + onSetManySubjectAccess={(selections) => + allowSelectiveAccess(selectedView, selections) + } + /> + + {isSubjectPanelSwitching ? ( +
+ ) : null} +
+ ) : ( +
+ Select a view row to manage custom subject access. +
+ )} +
+
+
+ + ); +} diff --git a/src/services/permissions/features.ts b/src/services/permissions/features.ts index 22ca5c9817..7d6c330ffb 100644 --- a/src/services/permissions/features.ts +++ b/src/services/permissions/features.ts @@ -24,7 +24,8 @@ export const features = { "settings.notifications": "settings.notifications", "settings.playbooks": "settings.playbooks", "settings.integrations": "settings.integrations", - "settings.permissions": "settings.permissions" + "settings.permissions": "settings.permissions", + "settings.mcp": "settings.mcp" } as const; export const featureToParentMap = { diff --git a/src/ui/FormControls/Switch.tsx b/src/ui/FormControls/Switch.tsx index fe37b69292..81655b93ee 100644 --- a/src/ui/FormControls/Switch.tsx +++ b/src/ui/FormControls/Switch.tsx @@ -1,40 +1,78 @@ import clsx from "clsx"; import { useEffect, useState } from "react"; +type SwitchSize = "default" | "sm" | "lg"; + type Props = { value?: T; onChange?: (value: T) => void; options: T[]; + size?: SwitchSize; className?: string; itemsClassName?: string; + activeItemClassName?: string; + getActiveItemClassName?: (option: T) => string | undefined; } & Omit, "onChange">; export function Switch({ onChange = () => {}, options, value, + size = "default", className, itemsClassName = "flex-1", + activeItemClassName, + getActiveItemClassName, ...props }: Props) { - const [activeOption, setActiveOption] = useState(() => value ?? options[0]); - const activeClasses = - "p-1.5 lg:pl-2.5 lg:pr-3.5 rounded-md flex items-center text-sm font-medium bg-white shadow-sm ring-1 ring-black ring-opacity-5"; - const inActiveClasses = - "p-1.5 lg:pl-2.5 lg:pr-3.5 rounded-md flex items-center text-sm font-medium"; + const fallbackOption = options[0]; + const [activeOption, setActiveOption] = useState( + () => value ?? fallbackOption + ); + const sizeClasses: Record< + SwitchSize, + { container: string; item: string; activeItem: string } + > = { + sm: { + container: "rounded-md p-0.5", + item: "px-2 py-1 text-xs", + activeItem: "shadow-sm" + }, + default: { + container: "rounded-lg p-0.5", + item: "p-1.5 lg:pl-2.5 lg:pr-3.5 text-sm", + activeItem: "shadow-sm" + }, + lg: { + container: "rounded-lg p-1", + item: "px-3 py-2 text-sm lg:px-4 lg:py-2.5", + activeItem: "shadow" + } + }; + + const activeClasses = clsx( + "rounded-md flex items-center font-medium bg-white ring-1 ring-black ring-opacity-5", + sizeClasses[size].item, + sizeClasses[size].activeItem + ); + const inActiveClasses = clsx( + "rounded-md flex items-center font-medium", + sizeClasses[size].item + ); function handleClick(view: T) { onChange(view); } useEffect(() => { - setActiveOption(value ?? options[0]); - }, [options, value]); + setActiveOption(value ?? fallbackOption); + }, [fallbackOption, value]); return (
({ type="button" className={`${itemsClassName} items-center whitespace-nowrap rounded-md text-sm font-medium text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-100`} tabIndex={0} - onClick={(e) => handleClick(option)} + onClick={() => handleClick(option)} key={option.toString()} > {option} diff --git a/src/ui/Modal/index.tsx b/src/ui/Modal/index.tsx index 027b631006..6ca4a0b41d 100644 --- a/src/ui/Modal/index.tsx +++ b/src/ui/Modal/index.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { XIcon } from "@heroicons/react/solid"; import clsx from "clsx"; import { atom, useAtom } from "jotai"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { BsArrowsFullscreen, BsFullscreenExit } from "react-icons/bs"; import { useWindowSize } from "react-use-size"; import DialogButton from "../Buttons/DialogButton"; @@ -44,6 +44,7 @@ export interface IModalProps { children?: React.ReactNode; containerClassName?: string; dialogClassName?: string; + allowBodyScroll?: boolean; } export function useDialogSize(size?: string): { @@ -142,6 +143,7 @@ export function Modal({ containerClassName = "overflow-auto max-h-full", dialogClassName = "fixed z-50 inset-0 overflow-y-auto min-h-2xl:my-20 py-4", helpLink: helpLinkProps, + allowBodyScroll = false, ...rest }: IModalProps) { const [_helpLink, setHelpLink] = useAtom(modalHelpLinkAtom); @@ -150,13 +152,44 @@ export function Modal({ const isSmall = _size === "very-small" || _size === "small"; const helpLink = _helpLink || helpLinkProps; + const resolvedDialogClassName = allowBodyScroll + ? "fixed z-50 inset-0 overflow-y-auto py-4" + : dialogClassName; + const resolvedDialogPanelClassName = clsx( + "flex justify-center", + allowBodyScroll ? "items-start" : "items-center", + allowBodyScroll ? "mx-auto" : sizeClass.classNames, + sizeClass.width + ); + + useEffect(() => { + if (!open || !allowBodyScroll) { + return; + } + + const html = document.documentElement; + const body = document.body; + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + const prevBodyPaddingRight = body.style.paddingRight; + + html.style.overflow = "auto"; + body.style.overflow = "auto"; + body.style.paddingRight = "0px"; + + return () => { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + body.style.paddingRight = prevBodyPaddingRight; + }; + }, [allowBodyScroll, open]); return ( { // reset the help link when the modal is closed, this is to ensure // that the help link is not displayed when the modal is reopened and @@ -173,21 +206,18 @@ export function Modal({ transition className="fixed inset-0 bg-black/30 duration-300 ease-out data-[closed]:opacity-0" /> - +
{children} From 8dcad2e0d008049f79fb8ed7ce30e57e55dea27e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Apr 2026 01:57:07 +0000 Subject: [PATCH 49/72] chore(release): 1.4.233 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 35cc5d4b1e..d15303982c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.232", + "version": "1.4.233", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 8ae4587b81..6902f02548 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.232", + "version": "1.4.233", "private": false, "files": [ "build", From 80e293f45d84ad7b84fa8d16c4d30970a68927a4 Mon Sep 17 00:00:00 2001 From: Yash Mehrotra Date: Tue, 14 Apr 2026 16:54:06 +0530 Subject: [PATCH 50/72] chore: live tail mode for config changes (#2980) * chore: live tail mode for config changes * chore: changes * chore: update merge logic * chore: fix build error * chore: use from_inserted_at for live mode * chore: refetch when live mode is turned off --- src/api/query-hooks/useConfigChangesHooks.ts | 19 +++- src/api/services/configs.ts | 5 + src/api/types/configs.ts | 1 + .../ConfigChangesFilters.tsx | 3 + .../FilterBar/ConfigRelatedChangesFilters.tsx | 5 +- src/pages/config/ConfigChangesPage.tsx | 101 ++++++++++++++++-- .../details/ConfigDetailsChangesPage.tsx | 94 ++++++++++++++-- 7 files changed, 207 insertions(+), 21 deletions(-) diff --git a/src/api/query-hooks/useConfigChangesHooks.ts b/src/api/query-hooks/useConfigChangesHooks.ts index 65e6586da7..c434775dab 100644 --- a/src/api/query-hooks/useConfigChangesHooks.ts +++ b/src/api/query-hooks/useConfigChangesHooks.ts @@ -39,9 +39,11 @@ function useConfigChangesTagsFilter(paramPrefix?: string) { export function useGetAllConfigsChangesQuery( { paramPrefix, + from_inserted_at, ...queryOptions }: UseQueryOptions & { paramPrefix?: string; + from_inserted_at?: string; } = { enabled: true, keepPreviousData: true @@ -73,8 +75,9 @@ export function useGetAllConfigsChangesQuery( include_deleted_configs: showChangesFromDeletedConfigs, changeType, severity, - from, - to, + from: from_inserted_at ? undefined : from, + to: from_inserted_at ? undefined : to, + from_inserted_at, configTypes, configType, sortBy: sortBy[0]?.id, @@ -93,7 +96,12 @@ export function useGetAllConfigsChangesQuery( } export function useGetConfigChangesByIDQuery( - queryOptions: UseQueryOptions = { + { + from_inserted_at, + ...queryOptions + }: UseQueryOptions & { + from_inserted_at?: string; + } = { enabled: true, keepPreviousData: true } @@ -141,8 +149,9 @@ export function useGetConfigChangesByIDQuery( include_deleted_configs: showChangesFromDeletedConfigs, changeType: change_type, severity, - from, - to, + from: from_inserted_at ? undefined : from, + to: from_inserted_at ? undefined : to, + from_inserted_at, configTypes, sortBy: sortBy[0]?.id, sortOrder: sortBy[0]?.desc ? "desc" : "asc", diff --git a/src/api/services/configs.ts b/src/api/services/configs.ts index 98d0fafab8..8648c265f4 100644 --- a/src/api/services/configs.ts +++ b/src/api/services/configs.ts @@ -230,6 +230,7 @@ export type GetConfigsRelatedChangesParams = { severity?: string; from?: string; to?: string; + from_inserted_at?: string; configTypes?: string; configType?: string; pageSize?: number | string; @@ -252,6 +253,7 @@ export async function getConfigsChanges({ severity, from, to, + from_inserted_at, configTypes, configType, pageIndex, @@ -302,6 +304,9 @@ export async function getConfigsChanges({ if (to) { requestData.set("to", to); } + if (from_inserted_at) { + requestData.set("from_inserted_at", from_inserted_at); + } if (severity) { requestData.set("severity", severity); } diff --git a/src/api/types/configs.ts b/src/api/types/configs.ts index a0cfc4cb73..eeb3215adb 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -23,6 +23,7 @@ export interface ConfigChange extends CreatedAt { tags?: Record; first_observed?: string; count?: number; + inserted_at?: string; } export interface Change { diff --git a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx index 9b0b05566e..ee0e455c18 100644 --- a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx @@ -67,11 +67,13 @@ export function FilterBadge({ filters, paramKey }: FilterBadgeProps) { type ConfigChangeFiltersProps = React.HTMLProps & { paramsToReset?: string[]; + extra?: React.ReactNode; }; export function ConfigChangeFilters({ className, paramsToReset = [], + extra, ...props }: ConfigChangeFiltersProps) { const [params] = useSearchParams(); @@ -100,6 +102,7 @@ export function ConfigChangeFilters({ + {extra}
diff --git a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx index 990e163753..eb16137ce8 100644 --- a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx @@ -13,11 +13,13 @@ import ConfigTypesTristateDropdown from "../../ConfigChangesFilters/ConfigTypesT type ConfigChangeFiltersProps = { className?: string; paramsToReset?: string[]; + extra?: React.ReactNode; }; export function ConfigRelatedChangesFilters({ className, - paramsToReset = [] + paramsToReset = [], + extra }: ConfigChangeFiltersProps) { const arbitraryFilters = useConfigChangesArbitraryFilters(); @@ -35,6 +37,7 @@ export function ConfigRelatedChangesFilters({ + {extra}
diff --git a/src/pages/config/ConfigChangesPage.tsx b/src/pages/config/ConfigChangesPage.tsx index 5a614b9d00..32ac2fd272 100644 --- a/src/pages/config/ConfigChangesPage.tsx +++ b/src/pages/config/ConfigChangesPage.tsx @@ -1,4 +1,5 @@ import { useGetAllConfigsChangesQuery } from "@flanksource-ui/api/query-hooks/useConfigChangesHooks"; +import { ConfigChange } from "@flanksource-ui/api/types/configs"; import { ConfigChangeTable } from "@flanksource-ui/components/Configs/Changes/ConfigChangeTable"; import { ConfigChangeFilters } from "@flanksource-ui/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters"; import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; @@ -12,9 +13,22 @@ import { import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; import { refreshButtonClickedTrigger } from "@flanksource-ui/ui/SlidingSideBar/SlidingSideBar"; +import { Toggle } from "@flanksource-ui/ui/FormControls/Toggle"; import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; +function getNewestInsertedAt(changes: ConfigChange[]): string | undefined { + let latest: string | undefined; + for (const c of changes) { + const ts = typeof c.inserted_at === "string" ? c.inserted_at : undefined; + if (ts && (!latest || ts > latest)) { + latest = ts; + } + } + return latest; +} + export function ConfigChangesPage() { const [, setRefreshButtonClickedTrigger] = useAtom( refreshButtonClickedTrigger @@ -31,22 +45,84 @@ export function ConfigChangesPage() { const pageSize = params.get("pageSize") ?? "200"; + const [liveTail, setLiveTail] = useState(false); + const [tailCursor, setTailCursor] = useState(undefined); + const [tailedChanges, setTailedChanges] = useState([]); + const { data, isLoading, error, isRefetching, refetch } = useGetAllConfigsChangesQuery({ keepPreviousData: true }); - const changes = (data?.changes ?? []).map((changes) => ({ - ...changes, + // Initialize cursor from base data when live tail is turned on + useEffect(() => { + if (liveTail && data?.changes?.length && !tailCursor) { + setTailCursor(getNewestInsertedAt(data.changes)); + } + }, [liveTail, data, tailCursor]); + + // Reset when live tail is turned off + useEffect(() => { + if (!liveTail) { + setTailedChanges([]); + setTailCursor(undefined); + refetch(); + } + }, [liveTail, refetch]); + + const { data: pollData } = useGetAllConfigsChangesQuery({ + from_inserted_at: tailCursor, + keepPreviousData: false, + enabled: liveTail && !!tailCursor, + refetchInterval: liveTail ? 5000 : false + }); + + // Accumulate new items from poll and advance cursor + useEffect(() => { + if (!pollData?.changes?.length) return; + + const incoming = pollData.changes; + const newest = getNewestInsertedAt(incoming); + if (newest) { + setTailCursor((prev) => (!prev || newest > prev ? newest : prev)); + } + + setTailedChanges((prev) => { + const incomingIds = new Set(incoming.map((c) => c.id)); + const filtered = prev.filter((c) => !incomingIds.has(c.id)); + return [...incoming, ...filtered]; + }); + }, [pollData]); + + const baseChanges = (data?.changes ?? []).map((change) => ({ + ...change, + config: { + id: change.config_id!, + type: change.type!, + name: change.name!, + deleted_at: change.deleted_at + } + })); + + const tailedWithConfig = tailedChanges.map((change) => ({ + ...change, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name!, - deleted_at: changes.deleted_at + id: change.config_id!, + type: change.type!, + name: change.name!, + deleted_at: change.deleted_at } })); - const totalChanges = data?.total ?? 0; + const tailedIds = new Set(tailedWithConfig.map((c) => c.id)); + const baseWithoutTailed = baseChanges.filter((c) => !tailedIds.has(c.id)); + const baseIds = new Set(baseChanges.map((c) => c.id)); + const newTailedCount = tailedWithConfig.filter( + (c) => !baseIds.has(c.id) + ).length; + const changes = [...tailedWithConfig, ...baseWithoutTailed]; + + const totalChanges = (data?.total ?? 0) + newTailedCount; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); const errorMessage = @@ -99,7 +175,16 @@ export function ConfigChangesPage() { ) : ( <> - + + } + /> latest)) { + latest = ts; + } + } + return latest; +} + export function ConfigDetailsChangesPage() { const { id } = useParams(); @@ -15,21 +29,82 @@ export function ConfigDetailsChangesPage() { const pageSize = params.get("pageSize") ?? "200"; + const [liveTail, setLiveTail] = useState(false); + const [tailCursor, setTailCursor] = useState(undefined); + const [tailedChanges, setTailedChanges] = useState([]); + const { data, isLoading, error, refetch } = useGetConfigChangesByIDQuery({ keepPreviousData: true, enabled: !!id }); - const changes = (data?.changes ?? []).map((changes) => ({ - ...changes, + // Initialize cursor from base data when live tail is turned on + useEffect(() => { + if (liveTail && data?.changes?.length && !tailCursor) { + setTailCursor(getNewestInsertedAt(data.changes)); + } + }, [liveTail, data, tailCursor]); + + // Reset when live tail is turned off + useEffect(() => { + if (!liveTail) { + setTailedChanges([]); + setTailCursor(undefined); + refetch(); + } + }, [liveTail, refetch]); + + const { data: pollData } = useGetConfigChangesByIDQuery({ + from_inserted_at: tailCursor, + keepPreviousData: false, + enabled: liveTail && !!id && !!tailCursor, + refetchInterval: liveTail ? 5000 : false + }); + + // Accumulate new items from poll and advance cursor + useEffect(() => { + if (!pollData?.changes?.length) return; + + const incoming = pollData.changes; + const newest = getNewestInsertedAt(incoming); + if (newest) { + setTailCursor((prev) => (!prev || newest > prev ? newest : prev)); + } + + setTailedChanges((prev) => { + const incomingIds = new Set(incoming.map((c) => c.id)); + const filtered = prev.filter((c) => !incomingIds.has(c.id)); + return [...incoming, ...filtered]; + }); + }, [pollData]); + + const baseChanges = (data?.changes ?? []).map((change) => ({ + ...change, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name! + id: change.config_id!, + type: change.type!, + name: change.name! } })); - const totalChanges = data?.total ?? 0; + const tailedWithConfig = tailedChanges.map((change) => ({ + ...change, + config: { + id: change.config_id!, + type: change.type!, + name: change.name! + } + })); + + const tailedIds = new Set(tailedWithConfig.map((c) => c.id)); + const baseWithoutTailed = baseChanges.filter((c) => !tailedIds.has(c.id)); + const baseIds = new Set(baseChanges.map((c) => c.id)); + const newTailedCount = tailedWithConfig.filter( + (c) => !baseIds.has(c.id) + ).length; + const changes = [...tailedWithConfig, ...baseWithoutTailed]; + + const totalChanges = (data?.total ?? 0) + newTailedCount; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); if (error) { @@ -50,7 +125,12 @@ export function ConfigDetailsChangesPage() { >
- + + } + />
Date: Tue, 14 Apr 2026 11:25:04 +0000 Subject: [PATCH 51/72] chore(release): 1.4.234 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d15303982c..6417b6eaa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.233", + "version": "1.4.234", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 6902f02548..ef8f7f1b4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.233", + "version": "1.4.234", "private": false, "files": [ "build", From 5000ff879dcf586b31274afba744710625583e8d Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 15 Apr 2026 17:51:10 +0545 Subject: [PATCH 52/72] chore: reduce notification summary latency from repeated refetches (#2984) * fix(notifications): reduce summary query refetches * fix: loading indicator --- .../Settings/notifications/NotificationsPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pages/Settings/notifications/NotificationsPage.tsx b/src/pages/Settings/notifications/NotificationsPage.tsx index 4d73d11b14..d368472fec 100644 --- a/src/pages/Settings/notifications/NotificationsPage.tsx +++ b/src/pages/Settings/notifications/NotificationsPage.tsx @@ -18,7 +18,7 @@ export default function NotificationsPage() { const includeDeletedResources = useShowDeletedConfigs(); - const { data, isLoading, refetch, isRefetching } = useQuery({ + const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: [ "notifications_send_history_summary", pageIndex, @@ -42,10 +42,12 @@ export default function NotificationsPage() { return res; }, keepPreviousData: true, - staleTime: 0, - cacheTime: 0 + staleTime: 1000 * 60 }); + const isInitialLoading = isLoading; + const isRefetching = !isLoading && isFetching; + const totalEntries = data?.total; const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : -1; @@ -53,13 +55,13 @@ export default function NotificationsPage() {
Date: Wed, 15 Apr 2026 12:07:08 +0000 Subject: [PATCH 53/72] chore(release): 1.4.235 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6417b6eaa8..73aa580a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.234", + "version": "1.4.235", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index ef8f7f1b4e..0df792e570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.234", + "version": "1.4.235", "private": false, "files": [ "build", From 1b26a6610ae179b028ae441df74928c11cf33429 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:50:31 +0000 Subject: [PATCH 54/72] fix(views): resolve useEffect anti-patterns across Views and Audit-Report components (#2947) --- src/api/services/views.ts | 15 ++-- .../components/View/GlobalFiltersForm.tsx | 66 ++++++++++-------- .../components/View/ViewTableFilterForm.tsx | 51 ++++++++------ .../View/panels/TimeseriesPanel.tsx | 18 ++--- src/pages/views/ViewPage.tsx | 69 ++++++++++++++++--- src/pages/views/ViewsPage.tsx | 24 +++---- 6 files changed, 156 insertions(+), 87 deletions(-) diff --git a/src/api/services/views.ts b/src/api/services/views.ts index e2fa537f7c..2d66748352 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -369,19 +369,26 @@ export const getViewsByConfigId = async (configId: string) => { export const getViewIdByNamespaceAndName = async ( namespace: string, - name: string + name: string, + signal?: AbortSignal ) => { const res = await resolvePostGrestRequestWithPagination( ConfigDB.get( - `/views_summary?namespace=eq.${encodeURIComponent(namespace)}&name=eq.${encodeURIComponent(name)}&select=id` + `/views_summary?namespace=eq.${encodeURIComponent(namespace)}&name=eq.${encodeURIComponent(name)}&select=id`, + { signal } ) ); return res.data?.[0]?.id; }; -export const getViewIdByName = async (name: string) => { +export const getViewIdByName = async (name: string, signal?: AbortSignal) => { const res = await resolvePostGrestRequestWithPagination( - ConfigDB.get(`/views_summary?name=eq.${encodeURIComponent(name)}&select=id`) + ConfigDB.get( + `/views_summary?name=eq.${encodeURIComponent(name)}&select=id`, + { + signal + } + ) ); return res.data?.[0]?.id; }; diff --git a/src/pages/audit-report/components/View/GlobalFiltersForm.tsx b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx index 673f6f1808..9a0422eac2 100644 --- a/src/pages/audit-report/components/View/GlobalFiltersForm.tsx +++ b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx @@ -1,5 +1,5 @@ import { Form, Formik, useFormikContext } from "formik"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; import { ViewVariable } from "../../types"; @@ -18,11 +18,13 @@ function GlobalFiltersListener({ globalVarPrefix, currentVariables = EMPTY_VARIABLES }: GlobalFiltersFormProps): React.ReactElement { - const { values, setFieldValue } = - useFormikContext>(); - const [globalParams, setGlobalParams] = - usePrefixedSearchParams(globalVarPrefix); + const { values } = useFormikContext>(); + const [, setGlobalParams] = usePrefixedSearchParams(globalVarPrefix); + // Sync form → URL whenever values change. + // The reverse direction (URL → form) is handled by initialValues in the + // parent so that we avoid an Effect chain (write URL → read URL → set field + // → write URL → …). useEffect(() => { setGlobalParams(() => { const newParams = new URLSearchParams(); @@ -39,28 +41,6 @@ function GlobalFiltersListener({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [values, setGlobalParams]); - // Initialize form values when variables load or URL params change - useEffect(() => { - variables.forEach((variable) => { - const urlValue = globalParams.get(variable.key); - const currentValue = currentVariables[variable.key]; - const optionItems = variable.optionItems ?? []; - const defaultValue = - variable.default ?? - (optionItems.length > 0 - ? optionItems[0].value - : variable.options.length > 0 - ? variable.options[0] - : ""); - - const valueToUse = urlValue || currentValue || defaultValue; - if (valueToUse) { - setFieldValue(variable.key, valueToUse); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [globalParams.toString(), variables, currentVariables, setFieldValue]); - return children as React.ReactElement; } @@ -75,9 +55,38 @@ export default function GlobalFiltersForm({ globalVarPrefix, currentVariables }: GlobalFiltersFormProps) { + const [globalParams] = usePrefixedSearchParams(globalVarPrefix); + + // Compute initialValues from URL params + variable defaults so that the + // Formik form starts with the correct values without needing a + // "read URL → setFieldValue" Effect. + // `globalParams` is included in deps so that external URL changes (browser + // back/forward) trigger a reinitialisation. Formik's deep-equality check + // in `enableReinitialize` prevents spurious resets when the URL simply + // reflects the current form values. + const initialValues = useMemo>(() => { + const values: Record = {}; + variables.forEach((variable) => { + const urlValue = globalParams.get(variable.key); + const currentValue = currentVariables?.[variable.key]; + const optionItems = variable.optionItems ?? []; + const defaultValue = + variable.default ?? + (optionItems.length > 0 + ? optionItems[0].value + : variable.options.length > 0 + ? variable.options[0] + : ""); + + const valueToUse = urlValue || currentValue || defaultValue; + if (valueToUse) values[variable.key] = valueToUse; + }); + return values; + }, [globalParams, variables, currentVariables]); + return ( { // Form submission is handled by the listener }} @@ -87,7 +96,6 @@ export default function GlobalFiltersForm({ {children} diff --git a/src/pages/audit-report/components/View/ViewTableFilterForm.tsx b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx index 1bf742fe04..a59e8dc882 100644 --- a/src/pages/audit-report/components/View/ViewTableFilterForm.tsx +++ b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx @@ -1,5 +1,5 @@ import { Form, Formik, useFormikContext } from "formik"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; type ViewTableFilterFormProps = { @@ -21,16 +21,18 @@ type ViewTableFilterFormProps = { function ViewTableFilterListener({ children, filterFields, - defaultFieldValues = {}, tablePrefix -}: ViewTableFilterFormProps): React.ReactElement { - const { values, setFieldValue } = - useFormikContext>(); - const [tableParams, setTableParams] = usePrefixedSearchParams( - tablePrefix, - false - ); +}: Omit< + ViewTableFilterFormProps, + "defaultFieldValues" +>): React.ReactElement { + const { values } = useFormikContext>(); + const [, setTableParams] = usePrefixedSearchParams(tablePrefix, false); + // Sync form → URL whenever values change. + // The reverse direction (URL → form) is handled by initialValues in the + // parent so we avoid a write-URL → read-URL → setFieldValue → write-URL + // Effect chain. useEffect(() => { setTableParams((current) => { const newParams = new URLSearchParams(current); @@ -63,16 +65,7 @@ function ViewTableFilterListener({ return newParams; }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values, setFieldValue, setTableParams]); - - // Reset form values when table filter params change - useEffect(() => { - filterFields.forEach((field) => { - const value = tableParams.get(field) || defaultFieldValues[field]; - setFieldValue(field, value); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableParams.toString(), filterFields, setFieldValue]); + }, [values, setTableParams]); return children as React.ReactElement; } @@ -87,9 +80,26 @@ export default function ViewTableFilterForm({ defaultFieldValues = {}, tablePrefix }: ViewTableFilterFormProps) { + const [tableParams] = usePrefixedSearchParams(tablePrefix, false); + + // Compute initialValues from URL params + provided defaults so that the + // Formik form starts populated without a "read URL → setFieldValue" Effect. + // `tableParams` is included in deps so that external URL changes (browser + // back/forward) trigger a reinitialisation. Formik's deep-equality check in + // `enableReinitialize` prevents spurious resets when the URL merely mirrors + // the current form values. + const initialValues = useMemo>(() => { + const values: Record = {}; + filterFields.forEach((field) => { + const value = tableParams.get(field) || defaultFieldValues[field]; + if (value) values[field] = value; + }); + return values; + }, [tableParams, filterFields, defaultFieldValues]); + return ( { // Form submission is handled by the listener }} @@ -98,7 +108,6 @@ export default function ViewTableFilterForm({ {children} diff --git a/src/pages/audit-report/components/View/panels/TimeseriesPanel.tsx b/src/pages/audit-report/components/View/panels/TimeseriesPanel.tsx index d235b58530..85f7191c27 100644 --- a/src/pages/audit-report/components/View/panels/TimeseriesPanel.tsx +++ b/src/pages/audit-report/components/View/panels/TimeseriesPanel.tsx @@ -71,10 +71,6 @@ const TimeseriesPanel: React.FC = ({ summary }) => { const rows = useMemo(() => summary.rows || [], [summary.rows]); const chartWrapperRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); - const [cachedTicks, setCachedTicks] = useState(); - const [cachedDomain, setCachedDomain] = useState< - [number, number] | undefined - >(); useEffect(() => { const el = chartWrapperRef.current; @@ -160,20 +156,20 @@ const TimeseriesPanel: React.FC = ({ summary }) => { return { min, max, span: max - min }; }, [chartData]); - useEffect(() => { - if (!timeRange) return; - // Cache domain once per data load - setCachedDomain([timeRange.min, timeRange.max]); + // Derived directly from timeRange — no state or Effects needed. + const cachedDomain = useMemo<[number, number] | undefined>(() => { + if (!timeRange) return undefined; + return [timeRange.min, timeRange.max]; }, [timeRange]); - useEffect(() => { - if (!timeRange || !containerWidth) return; + const cachedTicks = useMemo(() => { + if (!timeRange || !containerWidth) return undefined; const targetTickCount = clamp(Math.floor(containerWidth / 70), 4, 16); const spaced = buildEvenlySpacedRange( { min: timeRange.min, max: timeRange.max }, targetTickCount ); - setCachedTicks(spaced.ticks); + return spaced.ticks; }, [timeRange, containerWidth]); const hasNoRows = rows.length === 0; diff --git a/src/pages/views/ViewPage.tsx b/src/pages/views/ViewPage.tsx index 57df2c96f6..678162b1e3 100644 --- a/src/pages/views/ViewPage.tsx +++ b/src/pages/views/ViewPage.tsx @@ -21,51 +21,100 @@ export function ViewPage() { namespace?: string; name?: string; }>(); - const [viewId, setViewId] = useState(id); + // `id` is directly available from the route — no need to duplicate it in state. + // `fetchedId` holds the resolved ID when we had to look it up by name/namespace. + const [fetchedId, setFetchedId] = useState(); + const [fetchedLookupKey, setFetchedLookupKey] = useState< + string | undefined + >(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const lookupKey = name + ? namespace + ? `${namespace}/${name}` + : name + : undefined; + + // Only use fetched IDs for the route that produced them. + const scopedFetchedId = + lookupKey && fetchedLookupKey === lookupKey ? fetchedId : undefined; + + // Derived: prefer the direct `id` param; fall back to whatever we fetched. + const viewId = id ?? scopedFetchedId; + useEffect(() => { + // When a direct `id` is present there is nothing to fetch. + // Clear lookup-only state so route transitions cannot leak stale values. if (id) { - setViewId(id); + setFetchedId(undefined); + setFetchedLookupKey(undefined); + setIsLoading(false); + setError(null); return; } - if (!name) { + if (!name || !lookupKey) { + setFetchedId(undefined); + setFetchedLookupKey(undefined); + setIsLoading(false); setError("No view identifier provided"); return; } + // Reset stale lookup value for the previous route while this route resolves. + setFetchedLookupKey(lookupKey); + setFetchedId(undefined); + + // AbortController lets us cancel the in-flight request if the component + // unmounts or the route params change before the response arrives, + // preventing stale-closure / race-condition state updates. + const controller = new AbortController(); + const fetchViewId = async () => { setIsLoading(true); setError(null); try { - let fetchedId: string | undefined; + let resolved: string | undefined; if (namespace) { - fetchedId = await getViewIdByNamespaceAndName(namespace, name); + resolved = await getViewIdByNamespaceAndName( + namespace, + name, + controller.signal + ); } else { - fetchedId = await getViewIdByName(name); + resolved = await getViewIdByName(name, controller.signal); } - if (!fetchedId) { + if (controller.signal.aborted) return; + + if (!resolved) { + setFetchedId(undefined); setError( `View not found: ${namespace ? `${namespace}/${name}` : name}` ); return; } - setViewId(fetchedId); + setFetchedLookupKey(lookupKey); + setFetchedId(resolved); } catch (err) { + if (controller.signal.aborted) return; + setFetchedId(undefined); setError(err ?? "Failed to load view"); } finally { - setIsLoading(false); + if (!controller.signal.aborted) { + setIsLoading(false); + } } }; fetchViewId(); - }, [id, namespace, name]); + + return () => controller.abort(); + }, [id, namespace, name, lookupKey]); if (isLoading) { return ( diff --git a/src/pages/views/ViewsPage.tsx b/src/pages/views/ViewsPage.tsx index 2063ce3fbe..3c03788d62 100644 --- a/src/pages/views/ViewsPage.tsx +++ b/src/pages/views/ViewsPage.tsx @@ -15,7 +15,7 @@ import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactT import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { AiFillPlusCircle } from "react-icons/ai"; import { ViewsList } from "./ViewsList"; import { @@ -31,6 +31,13 @@ export function ViewsPage() { const [isOpen, setIsOpen] = useState(false); const [editedRow, setEditedRow] = useState(); const [sortState] = useReactTableSortState(); + + // Centralise close logic so editedRow is always cleared at the event site + // rather than via a reactive Effect watching isOpen. + const closeModal = useCallback(() => { + setIsOpen(false); + setEditedRow(undefined); + }, []); const { pageIndex, pageSize } = useReactTablePaginationState(); const { @@ -64,7 +71,7 @@ export function ViewsPage() { }, onSuccess: () => { refetch(); - setIsOpen(false); + closeModal(); toastSuccess("View added successfully"); }, onError: (ex) => { @@ -82,7 +89,7 @@ export function ViewsPage() { }, onSuccess: () => { refetch(); - setIsOpen(false); + closeModal(); toastSuccess("View updated successfully"); }, onError: (ex) => { @@ -97,7 +104,7 @@ export function ViewsPage() { }, onSuccess: () => { refetch(); - setIsOpen(false); + closeModal(); toastSuccess("View deleted successfully"); }, onError: (ex) => { @@ -107,13 +114,6 @@ export function ViewsPage() { const isSubmitting = isCreatingView || isUpdatingView; - useEffect(() => { - if (isOpen) { - return; - } - setEditedRow(undefined); - }, [isOpen]); - return ( <> @@ -161,7 +161,7 @@ export function ViewsPage() {
(open ? setIsOpen(true) : closeModal())} onViewSubmit={onSubmit} onViewDelete={(data) => deleteView(data)} isSubmitting={isSubmitting} From 762874fdbf17e2403c2966edaf1b9df4351c501f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 17 Apr 2026 13:51:40 +0000 Subject: [PATCH 55/72] chore(release): 1.4.236 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73aa580a25..288a99544e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.235", + "version": "1.4.236", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 0df792e570..857428f7f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.235", + "version": "1.4.236", "private": false, "files": [ "build", From 412ec9b42b63d55913162530c8e2549cdad1ab75 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 16 Apr 2026 17:06:20 +0545 Subject: [PATCH 56/72] fix: improve error handling in ai chat --- app/api/chat/route.ts | 137 ++++++++++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 31 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a50adb4bd4..f06cb6da23 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -141,25 +141,74 @@ function buildLLMModel(connection: LLMConnection): LanguageModelV3 { } } +function tryParseJSON(value?: string) { + if (!value) { + return undefined; + } + + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function getErrorDetail(error: unknown): unknown { + if (error instanceof HttpError) { + return tryParseJSON(error.body); + } + + if (!(error instanceof Error) || !error.cause) { + return undefined; + } + + if (error.cause instanceof Error) { + const cause = error.cause as Error & { + code?: string; + errno?: string | number; + address?: string; + port?: number; + }; + + return { + message: cause.message, + code: cause.code, + errno: cause.errno, + address: cause.address, + port: cause.port + }; + } + + return error.cause; +} + export async function POST(req: Request) { + let mcpClient: Awaited> | undefined; + const wideEvent: Record = { event: "llm-conversation", timestamp: new Date().toISOString(), status: "started" }; - const { - messages, - alwaysAllowedTools = [] - }: { messages?: UIMessage[]; alwaysAllowedTools?: string[] } = - await req.json(); - if (!Array.isArray(messages)) { - return new Response("Invalid request body", { status: 400 }); - } + try { + const { + messages, + alwaysAllowedTools = [] + }: { messages?: UIMessage[]; alwaysAllowedTools?: string[] } = + await req.json(); + + if (!Array.isArray(messages)) { + return new Response(JSON.stringify({ error: "Invalid request body" }), { + status: 400, + headers: { + "content-type": "application/json" + } + }); + } - wideEvent.messages = messages.length; + wideEvent.messages = messages.length; - try { const backendUrl = await getBackendUrl(); wideEvent.backendURL = backendUrl; @@ -167,24 +216,26 @@ export async function POST(req: Request) { wideEvent.cookies = cookies.length; const llmConnection = await fetchLLMConnection(backendUrl, cookies); - const model = buildLLMModel(llmConnection); wideEvent.llm = { model: llmConnection.properties?.model, provider: llmConnection.type }; - const mcpClient = await createMCPClient({ + const model = buildLLMModel(llmConnection); + + mcpClient = await createMCPClient({ transport: { type: "http", url: buildURL(backendUrl, "/mcp").toString(), headers: { - // Use the user's cookie to authenticate for now. - // We need to add the more fine-grained MCP tokens Cookie: cookies } } }); + wideEvent.mcpCreated = true; + const tools = await buildChatTools(mcpClient, alwaysAllowedTools); + wideEvent.totalTools = Object.entries(tools).length; const loadedSkillTool = await loadSkillTool(); wideEvent.skills = { @@ -201,7 +252,6 @@ export async function POST(req: Request) { (tools as Record).skill = loadedSkillTool.skillTool; } - // Build tools first so convertToModelMessages can resolve tool schemas const modelMessages = await convertToModelMessages(messages, { tools }); const result = streamText({ @@ -213,9 +263,16 @@ export async function POST(req: Request) { experimental_transform: truncateToolResultTransform, onError: async (error) => { wideEvent.status = "error"; - wideEvent.error = - error instanceof Error ? error.message : String(error); + wideEvent.error = { + error: + error instanceof Error ? error.message : "Internal Server Error", + detail: getErrorDetail(error), + provider: wideEvent.llm?.provider, + model: wideEvent.llm?.model + }; + await mcpClient?.close(); + console.error(JSON.stringify(wideEvent)); }, onFinish: async (result) => { wideEvent.status = "completed"; @@ -223,25 +280,43 @@ export async function POST(req: Request) { totalTokens: result.usage?.totalTokens }; wideEvent.finishReason = result.finishReason; + await mcpClient?.close(); + console.log(JSON.stringify(wideEvent)); } }); - return result.toUIMessageStreamResponse({ sendReasoning: true }); + return result.toUIMessageStreamResponse({ + sendReasoning: true, + onError: (error) => + JSON.stringify({ + error: + error instanceof Error ? error.message : "Internal Server Error", + detail: getErrorDetail(error), + provider: wideEvent.llm?.provider, + model: wideEvent.llm?.model + }) + }); } catch (error) { wideEvent.status = "error"; - wideEvent.error = error instanceof Error ? error.message : String(error); - if (error instanceof HttpError) { - return new Response(error.body ?? error.message, { - status: error.status - }); - } - return new Response("Internal Server Error", { status: 500 }); - } finally { - if (wideEvent.status === "error") { - console.error(JSON.stringify(wideEvent)); - } else { - console.log(JSON.stringify(wideEvent)); - } + wideEvent.error = { + error: error instanceof Error ? error.message : "Internal Server Error", + detail: getErrorDetail(error), + provider: wideEvent.llm?.provider, + model: wideEvent.llm?.model + }; + + try { + await mcpClient?.close(); + } catch {} + + console.error(JSON.stringify(wideEvent)); + + return new Response(JSON.stringify(wideEvent.error), { + status: error instanceof HttpError ? error.status : 500, + headers: { + "content-type": "application/json" + } + }); } } From f31334d08ee60499a8e9aafffa4beb130c9addfa Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 19 Apr 2026 08:17:41 +0000 Subject: [PATCH 57/72] chore(release): 1.4.237 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 288a99544e..a615d47ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.236", + "version": "1.4.237", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 857428f7f0..f5c46f25a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.236", + "version": "1.4.237", "private": false, "files": [ "build", From a0edde92ba6a29a0c0fbb696ff5c0759093b60d2 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 21 Apr 2026 18:08:14 +0545 Subject: [PATCH 58/72] feat: scrape snapshot UI (#2988) --- src/api/query-hooks/useJobsHistoryQuery.ts | 49 +- src/api/schemaResources.ts | 29 +- src/api/services/jobsHistory.ts | 58 + .../JobsHistory/JobsHistoryTable.tsx | 18 +- .../JobsHistory/JobsHistoryTableColumn.tsx | 57 +- src/components/ui/button.tsx | 2 +- .../settings/ConfigScrapersEditPage.tsx | 7 +- .../config/settings/ConfigScrapersPage.tsx | 139 +- .../settings/components/ScrapeRunDialog.tsx | 214 +++ .../settings/components/ScrapeRunViewer.tsx | 1156 +++++++++++++++++ .../components/ScrapeRunViewerDialog.tsx | 42 + .../settings/components/ScraperJobHistory.tsx | 114 ++ .../viewer/components/AccessLogTable.tsx | 188 +++ .../viewer/components/AccessTable.tsx | 227 ++++ .../viewer/components/AliasList.tsx | 47 + .../components/viewer/components/AnsiHtml.tsx | 102 ++ .../viewer/components/ConfigNode.tsx | 103 ++ .../viewer/components/ConfigTree.tsx | 88 ++ .../viewer/components/DetailPanel.tsx | 574 ++++++++ .../viewer/components/EntityTable.tsx | 431 ++++++ .../viewer/components/FilterBar.tsx | 90 ++ .../components/viewer/components/HARPanel.tsx | 202 +++ .../components/viewer/components/JsonView.tsx | 83 ++ .../viewer/components/ScrapeConfigPanel.tsx | 199 +++ .../viewer/components/ScraperList.tsx | 68 + .../viewer/components/SnapshotPanel.tsx | 413 ++++++ .../viewer/components/SplitPane.tsx | 86 ++ .../components/viewer/components/Summary.tsx | 103 ++ .../settings/components/viewer/globals.d.ts | 9 + .../components/viewer/hooks/useRoute.ts | 104 ++ .../components/viewer/hooks/useSort.tsx | 54 + .../settings/components/viewer/types.ts | 330 +++++ .../settings/components/viewer/utils.ts | 316 +++++ src/store/preference.state.ts | 1 - src/types/pako.d.ts | 1 + src/ui/Avatar/index.tsx | 1 + src/ui/MRTDataTable/MRTDataTable.tsx | 11 +- 37 files changed, 5608 insertions(+), 108 deletions(-) create mode 100644 src/pages/config/settings/components/ScrapeRunDialog.tsx create mode 100644 src/pages/config/settings/components/ScrapeRunViewer.tsx create mode 100644 src/pages/config/settings/components/ScrapeRunViewerDialog.tsx create mode 100644 src/pages/config/settings/components/ScraperJobHistory.tsx create mode 100644 src/pages/config/settings/components/viewer/components/AccessLogTable.tsx create mode 100644 src/pages/config/settings/components/viewer/components/AccessTable.tsx create mode 100644 src/pages/config/settings/components/viewer/components/AliasList.tsx create mode 100644 src/pages/config/settings/components/viewer/components/AnsiHtml.tsx create mode 100644 src/pages/config/settings/components/viewer/components/ConfigNode.tsx create mode 100644 src/pages/config/settings/components/viewer/components/ConfigTree.tsx create mode 100644 src/pages/config/settings/components/viewer/components/DetailPanel.tsx create mode 100644 src/pages/config/settings/components/viewer/components/EntityTable.tsx create mode 100644 src/pages/config/settings/components/viewer/components/FilterBar.tsx create mode 100644 src/pages/config/settings/components/viewer/components/HARPanel.tsx create mode 100644 src/pages/config/settings/components/viewer/components/JsonView.tsx create mode 100644 src/pages/config/settings/components/viewer/components/ScrapeConfigPanel.tsx create mode 100644 src/pages/config/settings/components/viewer/components/ScraperList.tsx create mode 100644 src/pages/config/settings/components/viewer/components/SnapshotPanel.tsx create mode 100644 src/pages/config/settings/components/viewer/components/SplitPane.tsx create mode 100644 src/pages/config/settings/components/viewer/components/Summary.tsx create mode 100644 src/pages/config/settings/components/viewer/globals.d.ts create mode 100644 src/pages/config/settings/components/viewer/hooks/useRoute.ts create mode 100644 src/pages/config/settings/components/viewer/hooks/useSort.tsx create mode 100644 src/pages/config/settings/components/viewer/types.ts create mode 100644 src/pages/config/settings/components/viewer/utils.ts create mode 100644 src/types/pako.d.ts diff --git a/src/api/query-hooks/useJobsHistoryQuery.ts b/src/api/query-hooks/useJobsHistoryQuery.ts index c542cc81b0..942676025b 100644 --- a/src/api/query-hooks/useJobsHistoryQuery.ts +++ b/src/api/query-hooks/useJobsHistoryQuery.ts @@ -6,7 +6,11 @@ import useTimeRangeParams from "@flanksource-ui/ui/Dates/TimeRangePicker/useTime import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { useMemo } from "react"; import { useSearchParams } from "react-router-dom"; -import { getJobsHistory, GetJobsHistoryParams } from "../services/jobsHistory"; +import { + getJobsHistory, + getJobsHistoryWithArtifacts, + GetJobsHistoryParams +} from "../services/jobsHistory"; type Response = | { error: Error; data: null; totalEntries: undefined } @@ -77,3 +81,46 @@ export function useJobsHistoryForSettingQuery( options ); } + +export function useScraperJobsHistoryForSettingQuery( + options?: UseQueryOptions, + resourceId?: string +) { + const { timeRangeAbsoluteValue } = useTimeRangeParams( + jobHistoryDefaultDateFilter + ); + + const [searchParams] = useSearchParams(); + const pageIndex = parseInt(searchParams.get("pageIndex") ?? "0"); + const pageSize = parseInt(searchParams.get("pageSize") ?? "150"); + const name = searchParams.get("name") ?? ""; + const sortBy = searchParams.get("sortBy") ?? ""; + const sortOrder = searchParams.get("sortOrder") ?? "desc"; + const status = searchParams.get("status") ?? ""; + const duration = searchParams.get("runDuration") ?? undefined; + const durationMillis = duration + ? durationOptions[duration].valueInMillis + : undefined; + const startsAt = timeRangeAbsoluteValue?.from ?? undefined; + const endsAt = timeRangeAbsoluteValue?.to ?? undefined; + + const params = { + pageIndex, + pageSize, + resourceType: "config_scraper", + name, + status, + sortBy, + sortOrder, + startsAt, + endsAt, + duration: durationMillis, + resourceId + } satisfies GetJobsHistoryParams; + + return useQuery( + ["jobs_history", "scraper", params], + () => getJobsHistoryWithArtifacts(params), + options + ); +} diff --git a/src/api/schemaResources.ts b/src/api/schemaResources.ts index be4d87b542..1517e8411b 100644 --- a/src/api/schemaResources.ts +++ b/src/api/schemaResources.ts @@ -213,6 +213,31 @@ export async function getIntegrationWithJobStatus(id: string) { return res.data?.[0]; } -export async function runConfigScraper(scraperId: string) { - return Config.post(`/run/${scraperId}`); +export type RunConfigScraperOptions = { + async?: boolean; + logLevel?: "trace" | "debug" | "info" | "warn" | "error"; + captureHAR?: boolean; + captureLogs?: boolean; + captureSnapshots?: boolean; +}; + +export async function runConfigScraper( + scraperId: string, + options: RunConfigScraperOptions = {} +) { + const { + async: asyncRun = true, + logLevel, + captureHAR, + captureLogs, + captureSnapshots + } = options; + + return Config.post(`/run/${scraperId}`, { + async: asyncRun, + ...(logLevel ? { logLevel } : {}), + ...(typeof captureHAR === "boolean" ? { captureHAR } : {}), + ...(typeof captureLogs === "boolean" ? { captureLogs } : {}), + ...(typeof captureSnapshots === "boolean" ? { captureSnapshots } : {}) + }); } diff --git a/src/api/services/jobsHistory.ts b/src/api/services/jobsHistory.ts index 5c42bab42c..c2d325073f 100644 --- a/src/api/services/jobsHistory.ts +++ b/src/api/services/jobsHistory.ts @@ -79,3 +79,61 @@ export const getJobsHistoryNames = async () => { ); return res.data ?? []; }; + +export const getJobsHistoryWithArtifacts = async ({ + pageIndex, + pageSize, + resourceType, + resourceId, + name, + status, + sortBy, + sortOrder, + startsAt, + endsAt, + duration +}: GetJobsHistoryParams) => { + const pagingParams = `&limit=${pageSize}&offset=${pageIndex * pageSize}`; + + const resourceTypeParam = resourceType + ? tristateOutputToQueryFilterParam(resourceType, "resource_type") + : ""; + + const resourceIdParam = resourceId ? `&resource_id=eq.${resourceId}` : ""; + + const nameParam = name ? tristateOutputToQueryFilterParam(name, "name") : ""; + + const statusParam = status + ? tristateOutputToQueryFilterParam(status, "status") + : ""; + + const sortByParam = sortBy ? `&order=${sortBy}` : "&order=created_at"; + + const sortOrderParam = sortOrder ? `.${sortOrder}` : ".desc"; + + const durationParam = duration ? `&duration_millis=gte.${duration}` : ""; + + const rangeParam = + startsAt && endsAt + ? `&and=(created_at.gt.${startsAt},created_at.lt.${endsAt})` + : ""; + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get( + `/job_histories?&select=*,artifacts:artifacts(id,job_history_id,filename,path,deleted_at)${pagingParams}${resourceTypeParam}${resourceIdParam}${nameParam}${statusParam}${sortByParam}${sortOrderParam}${rangeParam}${durationParam}`, + { + headers: { + Prefer: "count=exact" + } + } + ) + ); +}; + +export const getJobHistoryByID = async (jobHistoryID: string) => { + const res = await IncidentCommander.get( + `/job_histories?select=*&id=eq.${jobHistoryID}&limit=1` + ); + + return res.data?.[0] ?? null; +}; diff --git a/src/components/JobsHistory/JobsHistoryTable.tsx b/src/components/JobsHistory/JobsHistoryTable.tsx index ea926bf526..27f690a2ed 100644 --- a/src/components/JobsHistory/JobsHistoryTable.tsx +++ b/src/components/JobsHistory/JobsHistoryTable.tsx @@ -1,4 +1,5 @@ import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { MRT_ColumnDef } from "mantine-react-table"; import { useCallback, useState } from "react"; import { JobsHistoryDetails } from "./JobsHistoryDetails"; import { JobsHistoryTableColumn as jobsHistoryTableColumn } from "./JobsHistoryTableColumn"; @@ -46,6 +47,12 @@ export type JobHistory = { time_end: string; created_at: string; resource_name: string; + artifacts?: { + id: string; + filename?: string; + path?: string; + deleted_at?: string | null; + }[]; agent?: { id: string; name: string; @@ -59,6 +66,10 @@ type JobsHistoryTableProps = { pageCount: number; hiddenColumns?: string[]; totalJobHistoryItems?: number; + columns?: MRT_ColumnDef[]; + mantineTableBodyCellProps?: { + sx?: Record; + }; }; export default function JobsHistoryTable({ @@ -67,7 +78,9 @@ export default function JobsHistoryTable({ isRefetching, pageCount, hiddenColumns = [], - totalJobHistoryItems + totalJobHistoryItems, + columns, + mantineTableBodyCellProps }: JobsHistoryTableProps) { const [isModalOpen, setIsModalOpen] = useState(false); const [selectedJob, setSelectedJob] = useState(); @@ -88,7 +101,7 @@ export default function JobsHistoryTable({ <> {selectedJob && ( [] = [ } }, { - header: "Statistics", - id: "statistics", - enableHiding: true, - columns: [ - { - header: "Success", - id: "success_count", - accessorKey: "success_count", - size: 60, - maxSize: 100, - Cell: ({ row, column }) => { - const value = row.getValue(column.id); - if (value === 0) { - return null; - } - return {value}; - } - }, - { - header: "Error", - id: "error_count", - accessorKey: "error_count", - size: 50, - maxSize: 100, - Cell: ({ row, column }) => { - const value = row.getValue(column.id); - if (value === 0) { - return null; - } - return {value}; - } + header: "Success", + id: "success_count", + accessorKey: "success_count", + size: 60, + maxSize: 100, + Cell: ({ row, column }) => { + const value = row.getValue(column.id); + if (value === 0) { + return null; } - ] + return {value}; + } + }, + { + header: "Error", + id: "error_count", + accessorKey: "error_count", + size: 50, + maxSize: 100, + Cell: ({ row, column }) => { + const value = row.getValue(column.id); + if (value === 0) { + return null; + } + return {value}; + } } ]; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 23a6f42aae..13ea656577 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@flanksource-ui/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/src/pages/config/settings/ConfigScrapersEditPage.tsx b/src/pages/config/settings/ConfigScrapersEditPage.tsx index 575565a3ef..e8cd5270ca 100644 --- a/src/pages/config/settings/ConfigScrapersEditPage.tsx +++ b/src/pages/config/settings/ConfigScrapersEditPage.tsx @@ -1,7 +1,7 @@ import { useSettingsUpdateResource } from "@flanksource-ui/api/query-hooks/mutations/useSettingsResourcesMutations"; import { getResource } from "@flanksource-ui/api/schemaResources"; import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; -import { SchemaResourceJobsTab } from "@flanksource-ui/components/SchemaResourcePage/SchemaResourceEditJobsTab"; +import { ScraperJobHistory } from "./components/ScraperJobHistory"; import ConfigScrapperSpecEditor from "@flanksource-ui/components/SpecEditor/ConfigScrapperSpecEditor"; import { BreadcrumbChild, @@ -108,10 +108,7 @@ export default function ConfigScrapersEditPage() { label={"Job History"} value={"Job History"} > - +
diff --git a/src/pages/config/settings/ConfigScrapersPage.tsx b/src/pages/config/settings/ConfigScrapersPage.tsx index 0b32e45981..6151e76886 100644 --- a/src/pages/config/settings/ConfigScrapersPage.tsx +++ b/src/pages/config/settings/ConfigScrapersPage.tsx @@ -1,8 +1,8 @@ import { getAll, - runConfigScraper, SchemaResourceWithJobStatus } from "@flanksource-ui/api/schemaResources"; +import AgentBadge from "@flanksource-ui/components/Agents/AgentBadge"; import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; import AddSchemaResourceModal from "@flanksource-ui/components/SchemaResourcePage/AddSchemaResourceModal"; @@ -11,31 +11,27 @@ import { SchemaResourceType, schemaResourceTypes } from "@flanksource-ui/components/SchemaResourcePage/resourceTypes"; -import AgentBadge from "@flanksource-ui/components/Agents/AgentBadge"; import { DataTableTagsColumn, MRTJobHistoryStatusColumn } from "@flanksource-ui/components/Settings/ResourceTable"; -import { toastSuccess } from "@flanksource-ui/components/Toast/toast"; +import { ScrapeRunViewerDialog } from "./components/ScrapeRunViewerDialog"; +import { Avatar } from "@flanksource-ui/ui/Avatar"; import { BreadcrumbNav, BreadcrumbRoot } from "@flanksource-ui/ui/BreadcrumbNav"; -import { Avatar } from "@flanksource-ui/ui/Avatar"; -import { Button } from "@flanksource-ui/ui/Buttons/Button"; import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactTableSortState"; import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; -import { Modal } from "@flanksource-ui/ui/Modal"; import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { useQuery } from "@tanstack/react-query"; -import { Play } from "lucide-react"; -import { Oval } from "react-loading-icons"; import { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { ScrapeRunDialog } from "./components/ScrapeRunDialog"; +import { Button } from "@flanksource-ui/components/ui/button"; export const catalogScraperResourceInfo = schemaResourceTypes.find( (resource) => resource.table === "config_scrapers" @@ -45,62 +41,60 @@ type ConfigScraperRow = SchemaResourceWithJobStatus & { table: SchemaResourceType["table"]; }; -function RunScraperButton({ - scraperId, - onRunStart, - onRunComplete -}: { +type ScraperRunActionCellProps = { scraperId: string; - onRunStart: () => void; - onRunComplete: () => void; -}) { - const [isRunning, setIsRunning] = useState(false); - const [error, setError] = useState(null); + refetch: () => void; + setSearchParams: ReturnType[1]; +}; - const handleRun = async () => { - setIsRunning(true); - setError(null); - onRunStart(); - try { - await runConfigScraper(scraperId); - toastSuccess("Scraper ran successfully"); - } catch (err) { - setError(err); - } finally { - setIsRunning(false); - onRunComplete(); - } - }; +function ScraperRunActionCell({ + scraperId, + refetch, + setSearchParams +}: ScraperRunActionCellProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); return ( <> - setError(null)} - size="medium" - > -
- -
-
+ { + // Refetch after a delay, when the scraper job has started + setTimeout(() => { + refetch(); + }, 1000); + }} + onRunComplete={() => { + refetch(); + }} + onRunSuccess={({ jobHistoryId }) => { + if (!jobHistoryId) { + return; + } + + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("jobHistoryId", jobHistoryId); + next.set("scrapeTab", "spec"); + return next; + }); + }} + /> ); } @@ -109,6 +103,9 @@ export default function ConfigScrapersPage() { const navigate = useNavigate(); const [sortState] = useReactTableSortState(); + const [searchParams, setSearchParams] = useSearchParams(); + const jobHistoryId = searchParams.get("jobHistoryId") ?? undefined; + const isRunDialogOpen = !!jobHistoryId; const { data, refetch, isLoading, isRefetching } = useQuery({ queryKey: ["catalog", "catalog_scrapper", sortState], @@ -222,23 +219,16 @@ export default function ConfigScrapersPage() { Cell: ({ row }: { row: MRT_Row }) => { const { id } = row.original; return ( - { - // Refetch after a delay, when the scraper job has started - setTimeout(() => { - refetch(); - }, 1000); - }} - onRunComplete={() => { - refetch(); - }} + refetch={refetch} + setSearchParams={setSearchParams} /> ); } } ], - [refetch] + [refetch, setSearchParams] ); const dataWithTable = useMemo(() => { @@ -295,6 +285,29 @@ export default function ConfigScrapersPage() {
+ + {jobHistoryId && ( + { + if (!isOpen) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("jobHistoryId"); + next.delete("scrapeTab"); + next.delete("scrapeId"); + next.delete("scrapeQ"); + return next; + }, + { replace: true } + ); + } + }} + jobHistoryId={jobHistoryId} + title="Scrape Run Output" + /> + )} ); } diff --git a/src/pages/config/settings/components/ScrapeRunDialog.tsx b/src/pages/config/settings/components/ScrapeRunDialog.tsx new file mode 100644 index 0000000000..1cb313556a --- /dev/null +++ b/src/pages/config/settings/components/ScrapeRunDialog.tsx @@ -0,0 +1,214 @@ +import { runConfigScraper } from "@flanksource-ui/api/schemaResources"; +import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; +import { toastSuccess } from "@flanksource-ui/components/Toast/toast"; +import { Button } from "@flanksource-ui/ui/Buttons/Button"; +import { Modal } from "@flanksource-ui/ui/Modal"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { Switch } from "@flanksource-ui/components/ui/switch"; +import { Play } from "lucide-react"; +import { useState } from "react"; +import { Oval } from "react-loading-icons"; + +const RUN_LOG_LEVELS = ["trace", "debug", "info", "warn", "error"] as const; +type RunLogLevel = (typeof RUN_LOG_LEVELS)[number]; + +type RunScraperSuccessPayload = { + jobHistoryId?: string; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + scraperId: string; + onRunStart: () => void; + onRunComplete: () => void; + onRunSuccess: (payload: RunScraperSuccessPayload) => void; +}; + +export function ScrapeRunDialog({ + open, + onOpenChange, + scraperId, + onRunStart, + onRunComplete, + onRunSuccess +}: Props) { + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [logLevel, setLogLevel] = useState("info"); + const [captureHAR, setCaptureHAR] = useState(false); + const [captureLogs, setCaptureLogs] = useState(true); + const [captureSnapshots, setCaptureSnapshots] = useState(false); + const [openScrapeUI, setOpenScrapeUI] = useState(true); + + const handleRun = async () => { + setIsRunning(true); + setError(null); + onRunStart(); + try { + const response = await runConfigScraper(scraperId, { + logLevel, + captureHAR, + captureLogs, + captureSnapshots + }); + const payload = response?.data?.payload ?? response?.data; + const jobHistoryId = payload?.job_history_id; + + toastSuccess("Scraper started successfully"); + onOpenChange(false); + + if (openScrapeUI && jobHistoryId) { + onRunSuccess({ jobHistoryId }); + } + } catch (err) { + setError(err); + } finally { + setIsRunning(false); + onRunComplete(); + } + }; + + return ( + <> + + { + e.stopPropagation(); + }} + > + + Run scraper + + Configure this run before starting the scraper. + + + +
+
+
+

Log level

+ +
+ +
+
+

+ Capture logs +

+

+ Include scraper runtime logs in job details. +

+
+ +
+ +
+
+

+ Capture snapshots +

+

+ Capture and store snapshots while scraping. +

+
+ +
+ +
+
+

+ Capture HAR +

+

+ Capture HTTP archive (HAR) for the run. +

+
+ +
+ +
+
+

+ Open Scrape UI +

+

+ Automatically open run output dialog after starting. +

+
+ +
+
+
+ + + + + +
+
+ + setError(null)} + size="medium" + > +
+ +
+
+ + ); +} diff --git a/src/pages/config/settings/components/ScrapeRunViewer.tsx b/src/pages/config/settings/components/ScrapeRunViewer.tsx new file mode 100644 index 0000000000..5c48667f08 --- /dev/null +++ b/src/pages/config/settings/components/ScrapeRunViewer.tsx @@ -0,0 +1,1156 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import pako from "pako"; +import type { Counts, Snapshot, ScrapeResult, Tab } from "./viewer/types"; +import { + groupByType, + filterItems, + collectTypes, + buildLookups, + globalSearch, + matchesConfig +} from "./viewer/utils"; +import { useRoute } from "./viewer/hooks/useRoute"; +import { SplitPane } from "./viewer/components/SplitPane"; +import { ScraperList } from "./viewer/components/ScraperList"; +import { Summary } from "./viewer/components/Summary"; +import { FilterBar, type Filters } from "./viewer/components/FilterBar"; +import { ConfigTree } from "./viewer/components/ConfigTree"; +import { DetailPanel } from "./viewer/components/DetailPanel"; +import { AnsiHtml } from "./viewer/components/AnsiHtml"; +import { HARPanel } from "./viewer/components/HARPanel"; +import { EntityTable } from "./viewer/components/EntityTable"; +import { AccessTable } from "./viewer/components/AccessTable"; +import { AccessLogTable } from "./viewer/components/AccessLogTable"; +import { ScrapeConfigPanel } from "./viewer/components/ScrapeConfigPanel"; +import { SnapshotPanel } from "./viewer/components/SnapshotPanel"; +import { JsonView } from "./viewer/components/JsonView"; + +const TAB_DEFS: { key: Tab; label: string; icon: string; countKey?: string }[] = + [ + { + key: "configs", + label: "Configs", + icon: "codicon:server-process", + countKey: "configs" + }, + { key: "logs", label: "Logs", icon: "codicon:terminal" }, + { key: "har", label: "HTTP", icon: "codicon:globe" }, + { + key: "users", + label: "Users", + icon: "codicon:person", + countKey: "external_users" + }, + { + key: "groups", + label: "Groups", + icon: "codicon:organization", + countKey: "external_groups" + }, + { + key: "roles", + label: "Roles", + icon: "codicon:shield", + countKey: "external_roles" + }, + { + key: "access", + label: "Access", + icon: "codicon:lock", + countKey: "config_access" + }, + { + key: "access_logs", + label: "Access Logs", + icon: "codicon:history", + countKey: "access_logs" + }, + { key: "issues", label: "Issues", icon: "codicon:warning" }, + { key: "snapshot", label: "Snapshot", icon: "codicon:database" }, + { key: "last_summary", label: "Last Summary", icon: "codicon:pulse" }, + { key: "spec", label: "Spec", icon: "codicon:file-code" } + ]; + +function buildCounts(results: any, relationships: any[]): Counts { + const configs = results?.configs || []; + return { + configs: configs.length, + changes: (results?.changes || []).length, + analysis: (results?.analysis || []).length, + relationships: (relationships || []).length, + external_users: (results?.external_users || []).length, + external_groups: (results?.external_groups || []).length, + external_roles: (results?.external_roles || []).length, + config_access: (results?.config_access || []).length, + access_logs: (results?.config_access_logs || []).length, + errors: configs.filter((r: any) => !!r?.error).length + }; +} + +function toSnapshot(payload: any): Snapshot { + if (payload?.scrapers && payload?.results && payload?.counts) { + return payload as Snapshot; + } + + const results = payload?.results || {}; + const relationships = payload?.relationships || results?.relationships || []; + const startedAtMs = payload?.started_at + ? new Date(payload.started_at).getTime() + : Date.now(); + + const snapshots = payload?.snapshot_pair + ? { + [payload?.scraper_name || payload?.scraper_id || "run"]: + payload.snapshot_pair + } + : undefined; + + return { + scrapers: payload?.scrapers || [], + results, + relationships, + config_meta: payload?.config_meta, + issues: payload?.issues || [], + counts: buildCounts(results, relationships), + save_summary: payload?.save_summary, + snapshots, + scrape_spec: payload?.scrape_spec, + properties: payload?.properties, + log_level: payload?.log_level, + har: payload?.har || [], + logs: payload?.logs || "", + done: payload?.done ?? true, + started_at: Number.isFinite(startedAtMs) ? startedAtMs : Date.now(), + build_info: payload?.build_info, + last_scrape_summary: payload?.last_scrape_summary + }; +} + +const terminalStatuses = new Set(["SUCCESS", "FAILED", "WARNING", "STOPPED"]); + +function isTerminal(status?: string) { + return !!status && terminalStatuses.has(status); +} + +type JobHistoryRecord = { + status?: string; + time_start?: string; + created_at?: string; + details?: any; +}; + +type ArtifactRecord = { + id: string; + filename?: string; + path?: string; + created_at?: string; +}; + +async function parseArtifactError(response: Response, fallback: string) { + let message = fallback; + + try { + const raw = await response.text(); + if (raw) { + try { + const parsed = JSON.parse(raw); + message = parsed?.error || parsed?.message || raw; + } catch { + message = raw; + } + } + } catch { + // ignore body parsing errors and use fallback message + } + + return message; +} + +async function readArtifactText(response: Response): Promise { + if (!response.ok) { + const message = await parseArtifactError( + response, + `artifact download failed (${response.status})` + ); + throw new Error(message); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const isGzip = bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; + + return isGzip + ? (pako.ungzip(bytes, { to: "string" }) as string) + : new TextDecoder().decode(bytes); +} + +async function parseArtifactSnapshotResponse( + response: Response +): Promise { + const jsonText = await readArtifactText(response); + return toSnapshot(JSON.parse(jsonText)); +} + +async function parseArtifactJSONResponse(response: Response): Promise { + const jsonText = await readArtifactText(response); + return JSON.parse(jsonText) as T; +} + +function artifactName(artifact: ArtifactRecord): string { + const file = artifact.filename || artifact.path?.split("/").pop() || ""; + return file.toLowerCase(); +} + +function pickArtifact( + artifacts: ArtifactRecord[], + matcher: (name: string) => boolean +): ArtifactRecord | undefined { + return artifacts.find((artifact) => matcher(artifactName(artifact))); +} + +async function fetchJobHistory( + jobHistoryId: string +): Promise { + const response = await fetch( + `/api/db/job_histories?select=*&id=eq.${encodeURIComponent(jobHistoryId)}&limit=1` + ); + + if (!response.ok) { + const message = await parseArtifactError( + response, + `failed to fetch job history (${response.status})` + ); + throw new Error(message); + } + + const rows = (await response.json()) as JobHistoryRecord[]; + return rows?.[0] ?? null; +} + +async function fetchArtifactsForJobHistory( + jobHistoryId: string +): Promise { + const response = await fetch( + `/api/db/artifacts?select=id,filename,path,created_at&job_history_id=eq.${encodeURIComponent(jobHistoryId)}&deleted_at=is.null&order=created_at.desc` + ); + + if (!response.ok) { + const message = await parseArtifactError( + response, + `failed to fetch run artifacts (${response.status})` + ); + throw new Error(message); + } + + return (await response.json()) as ArtifactRecord[]; +} + +interface ScrapeRunViewerProps { + jobHistoryId: string; + syncRouteWithURL?: boolean; + containerClassName?: string; +} + +export function ScrapeRunViewer({ + jobHistoryId, + syncRouteWithURL = true, + containerClassName = "flex h-screen flex-col bg-gray-100" +}: ScrapeRunViewerProps) { + const [route, navigate] = useRoute({ + syncWithURL: syncRouteWithURL + }); + const { tab, id: routeId, q: routeQ } = route; + const [snapshot, setSnapshot] = useState(null); + const [done, setDone] = useState(false); + const [status, setStatus] = useState("Loading..."); + const [selected, setSelected] = useState(null); + const [expandAll, setExpandAll] = useState(null); + const [filters, setFilters] = useState({ + health: new Set(), + type: new Set() + }); + const [elapsed, setElapsed] = useState(0); + const search = routeQ || ""; + const setSearch = (value: string) => navigate({ q: value || undefined }); + const doneRef = useRef(false); + const startRef = useRef(0); + const logsRef = useRef(null); + const initialTabRef = useRef(tab); + const navigateRef = useRef(navigate); + const summaryArtifactIdRef = useRef(undefined); + const logsArtifactIdRef = useRef(undefined); + const harArtifactIdRef = useRef(undefined); + const snapshotsArtifactIdRef = useRef(undefined); + + useEffect(() => { + navigateRef.current = navigate; + }, [navigate]); + + const applySnap = useCallback((snap: Snapshot) => { + startRef.current = snap.started_at; + setSnapshot(snap); + if (snap.done) { + doneRef.current = true; + setDone(true); + setStatus("Scrape complete"); + setElapsed(Date.now() - snap.started_at); + } else { + setStatus("Scraping..."); + } + if ( + (snap.results?.configs?.length ?? 0) > 0 && + tabRef.current === "spec" && + initialTabRef.current === "spec" + ) { + navigateRef.current({ tab: "configs" }); + } + }, []); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + + const mergeSnapshot = (partial: Partial) => { + setSnapshot((prev) => { + if (!prev) return prev; + return { ...prev, ...partial }; + }); + }; + + const loadArtifacts = async (artifacts: ArtifactRecord[]) => { + const summaryArtifact = pickArtifact( + artifacts, + (name) => + name.includes("summary") && + (name.endsWith(".json") || name.endsWith(".json.gz")) + ); + + if ( + summaryArtifact && + summaryArtifact.id !== summaryArtifactIdRef.current + ) { + const snap = await fetch( + `/api/artifacts/download/${encodeURIComponent(summaryArtifact.id)}` + ).then(parseArtifactSnapshotResponse); + + if (cancelled) return; + summaryArtifactIdRef.current = summaryArtifact.id; + applySnap(snap); + } + + const logsArtifact = pickArtifact( + artifacts, + (name) => name === "logs.txt" || name === "logs.txt.gz" + ); + if (logsArtifact && logsArtifact.id !== logsArtifactIdRef.current) { + const logs = await fetch( + `/api/artifacts/download/${encodeURIComponent(logsArtifact.id)}` + ).then(readArtifactText); + + if (cancelled) return; + logsArtifactIdRef.current = logsArtifact.id; + mergeSnapshot({ logs }); + } + + const harArtifact = pickArtifact( + artifacts, + (name) => name === "har.json" || name === "har.json.gz" + ); + if (harArtifact && harArtifact.id !== harArtifactIdRef.current) { + const rawHar = await fetch( + `/api/artifacts/download/${encodeURIComponent(harArtifact.id)}` + ).then(parseArtifactJSONResponse); + + if (cancelled) return; + + const entries = Array.isArray(rawHar) + ? rawHar + : rawHar?.log?.entries || rawHar?.entries || []; + + harArtifactIdRef.current = harArtifact.id; + mergeSnapshot({ har: entries }); + } + + const snapshotsArtifact = pickArtifact( + artifacts, + (name) => name === "snapshots.json" || name === "snapshots.json.gz" + ); + if ( + snapshotsArtifact && + snapshotsArtifact.id !== snapshotsArtifactIdRef.current + ) { + const snapshots = await fetch( + `/api/artifacts/download/${encodeURIComponent(snapshotsArtifact.id)}` + ).then(parseArtifactJSONResponse>); + + if (cancelled) return; + snapshotsArtifactIdRef.current = snapshotsArtifact.id; + mergeSnapshot({ snapshots }); + } + }; + + const poll = async () => { + try { + const jobHistory = await fetchJobHistory(jobHistoryId); + + if (cancelled) return; + + if (!jobHistory) { + setStatus("Waiting for job history..."); + return; + } + + if (!startRef.current) { + const startedAt = + (jobHistory.time_start && + new Date(jobHistory.time_start).getTime()) || + (jobHistory.created_at && + new Date(jobHistory.created_at).getTime()) || + Date.now(); + startRef.current = startedAt; + } + + const currentStatus = jobHistory.status; + const terminal = isTerminal(currentStatus); + + if (!terminal) { + setStatus( + currentStatus ? `Scraping... (${currentStatus})` : "Scraping..." + ); + return; + } + + if (currentStatus === "FAILED") { + setStatus("Scrape failed"); + } else { + setStatus("Scrape complete"); + } + + const artifacts = await fetchArtifactsForJobHistory(jobHistoryId); + + if (cancelled) return; + + if (artifacts.length > 0) { + await loadArtifacts(artifacts); + } else { + setStatus("Run completed. Waiting for artifacts..."); + } + + const hasSummary = !!summaryArtifactIdRef.current; + const shouldComplete = + terminal && (currentStatus === "FAILED" || hasSummary); + + if (shouldComplete) { + doneRef.current = true; + setDone(true); + if (startRef.current) { + setElapsed(Date.now() - startRef.current); + } + } + } catch (error: unknown) { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load scrape run"; + setStatus(message); + } finally { + if (!cancelled && !doneRef.current) { + timer = setTimeout(poll, 2000); + } + } + }; + + doneRef.current = false; + setDone(false); + setStatus("Loading..."); + setSnapshot(null); + setSelected(null); + setElapsed(0); + startRef.current = 0; + summaryArtifactIdRef.current = undefined; + logsArtifactIdRef.current = undefined; + harArtifactIdRef.current = undefined; + snapshotsArtifactIdRef.current = undefined; + + poll(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [applySnap, jobHistoryId]); + + useEffect(() => { + const timer = setInterval(() => { + if (startRef.current && !doneRef.current) { + setElapsed(Date.now() - startRef.current); + } + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + const tabRef = useRef(tab); + tabRef.current = tab; + + // Auto-scroll logs + useEffect(() => { + if (tab === "logs" && logsRef.current) { + logsRef.current.scrollTop = logsRef.current.scrollHeight; + } + }, [snapshot?.logs, tab]); + + const configs = useMemo( + () => snapshot?.results?.configs || [], + [snapshot?.results?.configs] + ); + + // Sync selected config with URL route id (when on configs tab) + useEffect(() => { + if (tab !== "configs") return; + if (!routeId) { + setSelected(null); + return; + } + if (selected?.id === routeId) return; + const match = configs.find((c) => c.id === routeId); + if (match) setSelected(match); + }, [routeId, configs, tab, selected?.id]); + const orphanedConfigs = useMemo(() => { + return (snapshot?.issues || []) + .filter((issue) => issue.type === "orphaned" && issue.change) + .map( + (issue, i): ScrapeResult => ({ + id: `orphaned-${i}`, + name: + issue.change!.summary || + issue.change!.change_type || + `Orphaned #${i + 1}`, + config_type: "Orphaned Changes", + health: "warning", + config: issue.change + }) + ); + }, [snapshot?.issues]); + + const allConfigs = useMemo( + () => [...configs, ...orphanedConfigs], + [configs, orphanedConfigs] + ); + + const filtered = useMemo(() => { + let items = filterItems(allConfigs, filters.health, filters.type); + if (search) { + const lq = search.toLowerCase(); + items = items.filter( + (c) => + c.name?.toLowerCase().includes(lq) || + c.config_type?.toLowerCase().includes(lq) || + c.aliases?.some((a) => a.toLowerCase().includes(lq)) || + Object.entries(c.labels || {}).some( + ([k, v]) => + k.toLowerCase().includes(lq) || v.toLowerCase().includes(lq) + ) || + Object.entries(c.tags || {}).some( + ([k, v]) => + k.toLowerCase().includes(lq) || v.toLowerCase().includes(lq) + ) || + JSON.stringify(c.config)?.toLowerCase().includes(lq) + ); + } + return items; + }, [allConfigs, filters, search]); + const groups = useMemo(() => groupByType(filtered), [filtered]); + const types = useMemo(() => collectTypes(allConfigs), [allConfigs]); + const healthValues = useMemo(() => { + const vals = new Set(); + for (const item of allConfigs) vals.add(item.health || "unknown"); + return Array.from(vals).sort(); + }, [allConfigs]); + + const counts: Record = (snapshot?.counts as any) || {}; + + const zero = () => ({ + changes: 0, + access: 0, + accessLogs: 0, + analysis: 0, + relationships: 0 + }); + + const configCounts = useMemo(() => { + const m = new Map>(); + const changes = snapshot?.results?.changes || []; + const access = snapshot?.results?.config_access || []; + const logs = snapshot?.results?.config_access_logs || []; + const relationships = snapshot?.relationships || []; + + const configKey = (cfg: ScrapeResult) => `${cfg.config_type}-${cfg.id}`; + + for (const ch of changes) { + if (!ch.source) continue; + for (const cfg of configs) { + if (ch.source.includes(cfg.id)) { + const key = configKey(cfg); + const c = m.get(key) || zero(); + c.changes++; + m.set(key, c); + } + } + } + + for (const a of access) { + for (const cfg of configs) { + if (matchesConfig(a, cfg)) { + const key = configKey(cfg); + const c = m.get(key) || zero(); + c.access++; + m.set(key, c); + } + } + } + + for (const l of logs) { + for (const cfg of configs) { + if (matchesConfig(l, cfg)) { + const key = configKey(cfg); + const c = m.get(key) || zero(); + c.accessLogs++; + m.set(key, c); + } + } + } + + for (const rel of relationships) { + for (const cfg of configs) { + if (cfg.id === rel.config_id || cfg.id === rel.related_id) { + const key = configKey(cfg); + const c = m.get(key) || zero(); + c.relationships++; + m.set(key, c); + } + } + } + + return m; + }, [snapshot?.results, snapshot?.relationships, configs]); + + const lookups = useMemo( + () => buildLookups(snapshot?.results), + [snapshot?.results] + ); + + const searchCounts = useMemo( + () => + globalSearch(search, snapshot?.results, snapshot?.har, snapshot?.logs), + [search, snapshot?.results, snapshot?.har, snapshot?.logs] + ); + + const scraperErrors = useMemo( + () => + (snapshot?.scrapers || []).filter((s) => s.status === "error" && s.error), + [snapshot?.scrapers] + ); + + return ( +
+ {/* Header */} +
+
+
+

+ + Scrape Results +

+ {status} + {snapshot?.build_info && ( + + {snapshot.build_info.version} + {snapshot.build_info.commit && + snapshot.build_info.commit !== "none" && ( + <> · {snapshot.build_info.commit.substring(0, 8)} + )} + {snapshot.build_info.date && + snapshot.build_info.date !== "unknown" && ( + <> · {snapshot.build_info.date} + )} + + )} +
+ {snapshot && ( + + )} +
+ {snapshot && ( +
+ +
+ )} +
+ + {/* Scrape error banner — surfaces errors from failed scrapers so they + aren't just a small red chip in the scraper list. */} + {scraperErrors.length > 0 && ( +
+ {scraperErrors.map((s) => ( +
+ +
+
+ {s.name} failed +
+
+ {s.error} +
+
+
+ ))} +
+ )} + + {/* Tab bar */} +
+ {TAB_DEFS.map((t) => { + const count = t.countKey + ? counts[t.countKey] || 0 + : t.key === "har" + ? snapshot?.har?.length || 0 + : t.key === "logs" + ? snapshot?.logs + ? 1 + : 0 + : t.key === "issues" + ? snapshot?.issues?.length || 0 + : 0; + const isActive = tab === t.key; + const searchHits = search ? searchCounts[t.key] || 0 : 0; + + // Hide tabs with no data (except configs, logs, spec, snapshot, last_summary) + if ( + !count && + !isActive && + !searchHits && + !["configs", "logs", "spec", "snapshot", "last_summary"].includes( + t.key + ) + ) + return null; + + return ( + + ); + })} +
+
+ + setSearch((e.target as HTMLInputElement).value)} + className="w-64 rounded-md border border-gray-300 py-1 pl-7 pr-7 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + {search && ( + + )} +
+ +
+
+ + {/* Content */} +
+ {tab === "configs" && ( +
+
+ {configs.length > 0 && ( +
+ + + +
+ )} +
+ + {groups.map((g) => ( + + navigate({ tab: "configs", id: item.id }) + } + expandAll={expandAll} + configCounts={configCounts} + /> + ))} + {configs.length === 0 && !done && ( +
+ +

Waiting for scrape results...

+
+ )} + {filtered.length === 0 && configs.length > 0 && ( +
+ No items match the current filters +
+ )} + + } + right={ + navigate({ tab: kind, id })} + /> + } + /> +
+ )} + + {tab === "logs" && ( +
+ {snapshot?.logs ? ( + + ) : ( +
+ {done ? "No logs captured" : "Waiting for logs..."} +
+ )} +
+ )} + + {tab === "har" && ( + + )} + + {tab === "users" && ( + navigate({ tab: "users", id })} + /> + )} + {tab === "groups" && ( + navigate({ tab: "groups", id })} + /> + )} + {tab === "roles" && ( + navigate({ tab: "roles", id })} + /> + )} + {tab === "access" && ( + + )} + {tab === "access_logs" && ( + + )} + + {tab === "issues" && ( +
+ {!snapshot?.issues || snapshot.issues.length === 0 ? ( +
+ No issues found +
+ ) : ( +
+ {snapshot.issues.map((issue, i) => ( +
+
+ + {issue.type} + + {issue.message && ( + {issue.message} + )} + {issue.warning?.count && issue.warning.count > 1 && ( + + ×{issue.warning.count} + + )} +
+ {issue.change && ( +
+
+ change_type:{" "} + + {issue.change.change_type} + +
+ {issue.change.config_type && ( +
+ config_type:{" "} + {issue.change.config_type} +
+ )} + {issue.change.external_id && ( +
+ external_id:{" "} + + {issue.change.external_id} + +
+ )} + {issue.change.summary && ( +
+ summary:{" "} + {issue.change.summary} +
+ )} + {issue.change.source && ( +
+ source:{" "} + {issue.change.source} +
+ )} + {issue.change.severity && ( +
+ severity:{" "} + {issue.change.severity} +
+ )} + {issue.change.created_at && ( +
+ created_at:{" "} + {issue.change.created_at} +
+ )} +
+ )} + {issue.warning && ( +
+ {issue.warning.expr && ( +
+ expr:{" "} + + {issue.warning.expr} + +
+ )} + {issue.warning.input && ( +
+ + input + +
+ {typeof issue.warning.input === "object" ? ( + + ) : ( +
+                                  {String(issue.warning.input)}
+                                
+ )} +
+
+ )} + {issue.warning.output && ( +
+ + output + +
+ {typeof issue.warning.output === "object" ? ( + + ) : ( +
+                                  {String(issue.warning.output)}
+                                
+ )} +
+
+ )} + {issue.warning.result && ( +
+ + result + +
+ {typeof issue.warning.result === "object" ? ( + + ) : ( +
+                                  {String(issue.warning.result)}
+                                
+ )} +
+
+ )} +
+ )} +
+ ))} +
+ )} +
+ )} + + {tab === "snapshot" && } + {tab === "last_summary" && ( +
+ {snapshot?.last_scrape_summary ? ( + + ) : ( +
+ No previous scrape summary available (first run or no database + connection) +
+ )} +
+ )} + {tab === "spec" && ( + + )} +
+
+ ); +} diff --git a/src/pages/config/settings/components/ScrapeRunViewerDialog.tsx b/src/pages/config/settings/components/ScrapeRunViewerDialog.tsx new file mode 100644 index 0000000000..7b92eadb4e --- /dev/null +++ b/src/pages/config/settings/components/ScrapeRunViewerDialog.tsx @@ -0,0 +1,42 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { ScrapeRunViewer } from "./ScrapeRunViewer"; + +interface ScrapeRunViewerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + jobHistoryId: string; + title?: string; +} + +export function ScrapeRunViewerDialog({ + open, + onOpenChange, + jobHistoryId, + title = "Scrape Run" +}: ScrapeRunViewerDialogProps) { + return ( + + + + {title} + + Job History: {jobHistoryId} + + +
+ +
+
+
+ ); +} diff --git a/src/pages/config/settings/components/ScraperJobHistory.tsx b/src/pages/config/settings/components/ScraperJobHistory.tsx new file mode 100644 index 0000000000..080d3c9356 --- /dev/null +++ b/src/pages/config/settings/components/ScraperJobHistory.tsx @@ -0,0 +1,114 @@ +import { useScraperJobsHistoryForSettingQuery } from "@flanksource-ui/api/query-hooks/useJobsHistoryQuery"; +import ErrorPage from "@flanksource-ui/components/Errors/ErrorPage"; +import { + JobHistory, + default as JobsHistoryTable +} from "@flanksource-ui/components/JobsHistory/JobsHistoryTable"; +import { JobsHistoryTableColumn } from "@flanksource-ui/components/JobsHistory/JobsHistoryTableColumn"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { ScrapeRunViewerDialog } from "./ScrapeRunViewerDialog"; + +type ScraperJobHistoryProps = { + resourceId: string; +}; + +export function ScraperJobHistory({ resourceId }: ScraperJobHistoryProps) { + const [searchParams] = useSearchParams(); + const pageSize = parseInt(searchParams.get("pageSize") ?? "150"); + + const [isScrapeDialogOpen, setIsScrapeDialogOpen] = useState(false); + const [selectedJobHistoryId, setSelectedJobHistoryId] = useState< + string | undefined + >(); + + const { isLoading, isRefetching, data, error } = + useScraperJobsHistoryForSettingQuery( + { + keepPreviousData: true + }, + resourceId + ); + + const jobs = data?.data; + const totalEntries = data?.totalEntries; + const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : -1; + + const columns = useMemo[]>( + () => [ + ...JobsHistoryTableColumn, + { + id: "artifacts", + header: "Artifacts", + enableSorting: false, + size: 110, + Cell: ({ row }) => { + const artifacts = (row.original.artifacts ?? []).filter( + (artifact) => !artifact.deleted_at + ); + + if (artifacts.length === 0) { + return null; + } + + return ( + + ); + } + } + ], + [] + ); + + return ( +
+ {!data && error && !isLoading ? ( + + ) : ( + + )} + + {selectedJobHistoryId && ( + { + setIsScrapeDialogOpen(open); + if (!open) { + setSelectedJobHistoryId(undefined); + } + }} + jobHistoryId={selectedJobHistoryId} + title="Scrape Run Output" + /> + )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/AccessLogTable.tsx b/src/pages/config/settings/components/viewer/components/AccessLogTable.tsx new file mode 100644 index 0000000000..970e89670a --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/AccessLogTable.tsx @@ -0,0 +1,188 @@ +import { useState, useMemo } from "react"; +import type { ExternalConfigAccessLog } from "../types"; +import { useSort, SortIcon } from "../hooks/useSort"; +import { + type Lookups, + resolveConfigId, + resolve, + matchesSearch +} from "../utils"; +import { JsonView } from "./JsonView"; + +interface Props { + entries: ExternalConfigAccessLog[]; + lookups: Lookups; + search?: string; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: "external_config_id", label: "Config", cls: "px-3 py-2" }, + { key: "external_user_aliases", label: "User", cls: "px-3 py-2" }, + { key: "mfa", label: "MFA", cls: "px-3 py-2 w-16" }, + { key: "count", label: "Count", cls: "px-3 py-2 w-16 text-right" }, + { key: "created_at", label: "Timestamp", cls: "px-3 py-2" } +]; + +const HIDDEN_KEYS = new Set([ + "config_id", + "external_config_id", + "external_user_id", + "external_user_aliases", + "mfa", + "count", + "created_at", + "scraper_id" +]); + +function AccessLogRow({ + entry, + lookups +}: { + entry: ExternalConfigAccessLog; + lookups: Lookups; +}) { + const [open, setOpen] = useState(false); + + const extraProps = useMemo(() => { + const out: Record = {}; + for (const [k, v] of Object.entries(entry)) { + if (HIDDEN_KEYS.has(k)) continue; + if ( + v === null || + v === undefined || + v === "" || + v === "00000000-0000-0000-0000-000000000000" + ) + continue; + if (Array.isArray(v) && v.length === 0) continue; + if ( + typeof v === "object" && + !Array.isArray(v) && + Object.keys(v).length === 0 + ) + continue; + out[k] = v; + } + return out; + }, [entry]); + + return ( + <> + setOpen(!open)} + > + + {resolveConfigId( + lookups, + entry.external_config_id ?? entry.config_id + )} + + + {(entry.external_user_aliases?.length + ? entry.external_user_aliases + : entry.external_user_id + ? [entry.external_user_id] + : [] + ).map((a, j) => ( + + {resolve(lookups.users, a)} + + ))} + + + {typeof entry.mfa === "boolean" && ( + + {entry.mfa ? "Yes" : "No"} + + )} + + + {entry.count ?? ""} + + + {entry.created_at || ""} + + + {open && Object.keys(extraProps).length > 0 && ( + + + + + + )} + + ); +} + +export function AccessLogTable({ entries, lookups, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter((e) => { + const users = e.external_user_aliases?.length + ? e.external_user_aliases + : e.external_user_id + ? [e.external_user_id] + : []; + + return matchesSearch( + search, + resolveConfigId(lookups, e.external_config_id ?? e.config_id), + e.config_id, + e.external_user_id, + e.created_at, + ...users, + ...users.map((u) => resolve(lookups.users, u)) + ); + }); + }, [entries, lookups, search]); + const { sorted, sort, toggle } = useSort(filtered); + + if (!entries || entries.length === 0) { + return ( +
+ No access log entries +
+ ); + } + + return ( +
+ + + + {COLS.map((c) => ( + + ))} + + + + {sorted.map((e) => ( + + ))} + +
toggle(c.key)} + > + {c.label} + +
+
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/AccessTable.tsx b/src/pages/config/settings/components/viewer/components/AccessTable.tsx new file mode 100644 index 0000000000..9e4c88bdfc --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/AccessTable.tsx @@ -0,0 +1,227 @@ +import { useState, useMemo } from "react"; +import type { ExternalConfigAccess } from "../types"; +import { useSort, SortIcon } from "../hooks/useSort"; +import { + type Lookups, + resolveConfigId, + resolve, + matchesSearch +} from "../utils"; +import { JsonView } from "./JsonView"; + +interface Props { + entries: ExternalConfigAccess[]; + lookups: Lookups; + search?: string; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: "id", label: "ID", cls: "px-3 py-2" }, + { key: "external_config_id", label: "Config", cls: "px-3 py-2" }, + { key: "external_user_aliases", label: "User", cls: "px-3 py-2" }, + { key: "external_role_aliases", label: "Role", cls: "px-3 py-2" }, + { key: "external_group_aliases", label: "Group", cls: "px-3 py-2" }, + { key: "created_at", label: "Created", cls: "px-3 py-2" } +]; + +const HIDDEN_KEYS = new Set([ + "id", + "config_id", + "external_config_id", + "external_user_id", + "external_user_aliases", + "external_role_id", + "external_role_aliases", + "external_group_id", + "external_group_aliases", + "created_at" +]); + +function AccessRow({ + entry, + lookups +}: { + entry: ExternalConfigAccess; + lookups: Lookups; +}) { + const [open, setOpen] = useState(false); + + const extraProps = useMemo(() => { + const out: Record = {}; + for (const [k, v] of Object.entries(entry)) { + if (HIDDEN_KEYS.has(k)) continue; + if ( + v === null || + v === undefined || + v === "" || + v === "00000000-0000-0000-0000-000000000000" + ) + continue; + if (Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return out; + }, [entry]); + + return ( + <> + setOpen(!open)} + > + + {entry.id} + + + {resolveConfigId( + lookups, + entry.external_config_id ?? entry.config_id + )} + + + {(entry.external_user_aliases?.length + ? entry.external_user_aliases + : entry.external_user_id + ? [entry.external_user_id] + : [] + ).map((a, j) => ( + + {resolve(lookups.users, a)} + + ))} + + + {(entry.external_role_aliases?.length + ? entry.external_role_aliases + : entry.external_role_id + ? [entry.external_role_id] + : [] + ).map((a, j) => ( + + {resolve(lookups.roles, a)} + + ))} + + + {(entry.external_group_aliases?.length + ? entry.external_group_aliases + : entry.external_group_id + ? [entry.external_group_id] + : [] + ).map((a, j) => ( + + {resolve(lookups.groups, a)} + + ))} + + + {entry.created_at || ""} + + + {open && Object.keys(extraProps).length > 0 && ( + + + + + + )} + + ); +} + +export function AccessTable({ entries, lookups, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter((e) => { + const users = e.external_user_aliases?.length + ? e.external_user_aliases + : e.external_user_id + ? [e.external_user_id] + : []; + const roles = e.external_role_aliases?.length + ? e.external_role_aliases + : e.external_role_id + ? [e.external_role_id] + : []; + const groups = e.external_group_aliases?.length + ? e.external_group_aliases + : e.external_group_id + ? [e.external_group_id] + : []; + + return matchesSearch( + search, + e.id, + resolveConfigId(lookups, e.external_config_id ?? e.config_id), + e.config_id, + e.external_user_id, + e.external_role_id, + e.external_group_id, + e.created_at, + ...users, + ...roles, + ...groups, + ...users.map((u) => resolve(lookups.users, u)), + ...roles.map((r) => resolve(lookups.roles, r)), + ...groups.map((g) => resolve(lookups.groups, g)) + ); + }); + }, [entries, lookups, search]); + const { sorted, sort, toggle } = useSort(filtered); + + if (!entries || entries.length === 0) { + return ( +
+ No config access records +
+ ); + } + + return ( +
+ + + + {COLS.map((c) => ( + + ))} + + + + {sorted.map((e) => ( + + ))} + +
toggle(c.key)} + > + {c.label} + +
+
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/AliasList.tsx b/src/pages/config/settings/components/viewer/components/AliasList.tsx new file mode 100644 index 0000000000..deacb71936 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/AliasList.tsx @@ -0,0 +1,47 @@ +interface Props { + aliases?: string[]; +} + +export function AliasList({ aliases }: Props) { + if (!aliases || aliases.length === 0) return null; + return ( +
    + {aliases.map((alias, i) => ( +
  • + {alias} + +
  • + ))} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/AnsiHtml.tsx b/src/pages/config/settings/components/viewer/components/AnsiHtml.tsx new file mode 100644 index 0000000000..80c75bceda --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/AnsiHtml.tsx @@ -0,0 +1,102 @@ +import type { CSSProperties } from "react"; + +const ANSI_STYLES: Record = { + "30": { color: "#1e1e1e" }, + "31": { color: "#cd3131" }, + "32": { color: "#0dbc79" }, + "33": { color: "#e5e510" }, + "34": { color: "#2472c8" }, + "35": { color: "#bc3fbc" }, + "36": { color: "#11a8cd" }, + "37": { color: "#e5e5e5" }, + "90": { color: "#666" }, + "91": { color: "#f14c4c" }, + "92": { color: "#23d18b" }, + "93": { color: "#f5f543" }, + "94": { color: "#3b8eea" }, + "95": { color: "#d670d6" }, + "96": { color: "#29b8db" }, + "97": { color: "#fff" }, + "1": { fontWeight: "bold" }, + "2": { opacity: 0.7 }, + "3": { fontStyle: "italic" }, + "4": { textDecoration: "underline" } +}; + +interface Span { + text: string; + style?: CSSProperties; +} + +function parseAnsi(raw: string): Span[] { + const spans: Span[] = []; + // eslint-disable-next-line no-control-regex + const re = new RegExp("\\u001b\\[([0-9;]*)m", "g"); + let last = 0; + let style: CSSProperties = {}; + let match: RegExpExecArray | null; + + while ((match = re.exec(raw)) !== null) { + if (match.index > last) { + spans.push({ + text: raw.slice(last, match.index), + style: Object.keys(style).length ? { ...style } : undefined + }); + } + + const codes = + match[1] === "" ? ["0"] : match[1].split(";").map((code) => code || "0"); + for (const code of codes) { + if (code === "0") { + style = {}; + } else if (code === "22") { + const { fontWeight, opacity, ...rest } = style; + style = rest; + } else if (code === "23") { + const { fontStyle, ...rest } = style; + style = rest; + } else if (code === "24") { + const { textDecoration, ...rest } = style; + style = rest; + } else if (code === "39") { + const { color, ...rest } = style; + style = rest; + } else if (ANSI_STYLES[code]) { + style = { ...style, ...ANSI_STYLES[code] }; + } + } + + last = match.index + match[0].length; + } + + if (last < raw.length) { + spans.push({ + text: raw.slice(last), + style: Object.keys(style).length ? style : undefined + }); + } + + return spans; +} + +interface Props { + text: string; + className?: string; +} + +export function AnsiHtml({ text, className }: Props) { + const spans = parseAnsi(text); + return ( +
+      {spans.map((s, i) =>
+        s.style ? (
+          
+            {s.text}
+          
+        ) : (
+          s.text
+        )
+      )}
+    
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/ConfigNode.tsx b/src/pages/config/settings/components/viewer/components/ConfigNode.tsx new file mode 100644 index 0000000000..195151cb16 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/ConfigNode.tsx @@ -0,0 +1,103 @@ +import type { ScrapeResult } from "../types"; +import { healthIcon, healthColor } from "../utils"; + +export interface ConfigItemCounts { + changes: number; + access: number; + accessLogs: number; + analysis: number; + relationships: number; +} + +interface Props { + item: ScrapeResult; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + counts?: ConfigItemCounts; +} + +function Badge({ + count, + color, + label +}: { + count: number; + color: string; + label: string; +}) { + if (count === 0) return null; + return ( + + {count} + + ); +} + +function StatusDot({ color, title }: { color: string; title: string }) { + return ( + + ); +} + +export function ConfigNode({ item, selected, onSelect, counts }: Props) { + const isSelected = + selected?.id === item.id && selected?.config_type === item.config_type; + const isDeleted = !!item.deleted_at; + const isNew = + item.Action === "inserted" || (!item.Action && !!item.created_at); + const isUpdated = item.Action === "updated"; + + return ( +
onSelect(item)} + > + + {isNew && } + {isUpdated && } + {isDeleted && } + + {item.name || item.id} + + {counts && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/ConfigTree.tsx b/src/pages/config/settings/components/viewer/components/ConfigTree.tsx new file mode 100644 index 0000000000..53d220f4f7 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/ConfigTree.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect, useRef } from "react"; +import type { ScrapeResult, TypeGroup } from "../types"; +import { typeIcon } from "../utils"; +import { ConfigNode, type ConfigItemCounts } from "./ConfigNode"; + +interface Props { + groups: TypeGroup[]; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + expandAll: boolean | null; + configCounts?: Map; +} + +function TypeGroupNode({ + group, + selected, + onSelect, + expandAll, + configCounts +}: { + group: TypeGroup; + selected: ScrapeResult | null; + onSelect: (item: ScrapeResult) => void; + expandAll: boolean | null; + configCounts?: Map; +}) { + const [open, setOpen] = useState(true); + const prevExpandAll = useRef(expandAll); + + useEffect(() => { + if (expandAll !== null && expandAll !== prevExpandAll.current) { + setOpen(expandAll); + } + prevExpandAll.current = expandAll; + }, [expandAll]); + + return ( +
+
setOpen(!open)} + > + {open ? "▼" : "▶"} + + + {group.type} + + {group.items.length} +
+ {open && ( +
+ {group.items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export function ConfigTree({ + groups, + selected, + onSelect, + expandAll, + configCounts +}: Props) { + return ( +
+ {groups.map((group) => ( + + ))} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/DetailPanel.tsx b/src/pages/config/settings/components/viewer/components/DetailPanel.tsx new file mode 100644 index 0000000000..cde20ae4eb --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/DetailPanel.tsx @@ -0,0 +1,574 @@ +import { useState, useMemo } from "react"; +import type { + ScrapeResult, + ConfigChange, + UIRelationship, + ConfigMeta, + ExternalConfigAccess, + ExternalConfigAccessLog, + ExternalUser, + ExternalGroup, + ExternalRole +} from "../types"; +import { + healthIcon, + healthColor, + type Lookups, + resolve, + matchesConfig +} from "../utils"; +import { JsonView } from "./JsonView"; +import { AliasList } from "./AliasList"; + +type EntityKind = "users" | "groups" | "roles"; + +interface Props { + item: ScrapeResult | null; + changes?: ConfigChange[]; + relationships?: UIRelationship[]; + configMeta?: Record; + access?: ExternalConfigAccess[]; + accessLogs?: ExternalConfigAccessLog[]; + allUsers?: ExternalUser[]; + allGroups?: ExternalGroup[]; + allRoles?: ExternalRole[]; + lookups: Lookups; + // Optional navigate callback. When provided, entity badges become clickable + // links that navigate to /users/{id}, /groups/{id}, /roles/{id} via the + // SPA router. When omitted, badges fall back to plain spans. + onNavigate?: (kind: EntityKind, id: string) => void; +} + +function LabelBadges({ + labels, + color +}: { + labels?: Record; + color: string; +}) { + if (!labels) return null; + const entries = Object.entries(labels); + if (entries.length === 0) return null; + return ( +
+ {entries.map(([k, v]) => ( + + {k}={v} + + ))} +
+ ); +} + +function Expandable({ + summary, + data, + color +}: { + summary: any; + data: any; + color: string; +}) { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(!open)} + > + {open ? "▼" : "▶"} +
{summary}
+
+ {open && ( +
+ +
+ )} +
+ ); +} + +// resolveEntityID maps an alias-or-id back to the canonical entity .id by +// scanning the entity list. The badges in the Access section receive an +// alias from the access row (which may differ from the entity's primary id), +// so we resolve it before building the navigation URL — otherwise the +// /users/{id} route wouldn't match anything in the entity tab. +function resolveEntityID( + entities: T[] | undefined, + aliasOrId: string +): string { + if (!entities || !aliasOrId) return aliasOrId; + for (const e of entities) { + if (e.id === aliasOrId) return e.id; + if (e.aliases?.includes(aliasOrId)) return e.id; + } + return aliasOrId; +} + +interface EntityBadgeProps { + kind: EntityKind; + prefix: string; + aliasOrId: string; + display: string; + colorClass: string; + entities?: { id: string; aliases?: string[] }[]; + onNavigate?: (kind: EntityKind, id: string) => void; +} + +function EntityBadge({ + kind, + prefix, + aliasOrId, + display, + colorClass, + entities, + onNavigate +}: EntityBadgeProps) { + const canonicalId = resolveEntityID(entities, aliasOrId); + const href = `/${kind}/${encodeURIComponent(canonicalId)}`; + if (!onNavigate) { + return ( + + {prefix} + {display} + + ); + } + return ( + { + e.preventDefault(); + e.stopPropagation(); + onNavigate(kind, canonicalId); + }} + className={`rounded px-1.5 py-0.5 ${colorClass} cursor-pointer no-underline hover:brightness-95`} + > + {prefix} + {display} + + ); +} + +function Section({ + title, + count, + children, + defaultOpen = true +}: { + title: string; + count?: number; + children: any; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+

setOpen(!open)} + > + {open ? "▼" : "▶"} + {title} + {count !== undefined && ` (${count})`} +

+ {open && children} +
+ ); +} + +export function DetailPanel({ + item, + changes, + relationships, + configMeta, + access, + accessLogs, + allUsers, + allGroups, + allRoles, + lookups, + onNavigate +}: Props) { + const itemChanges = useMemo(() => { + if (!item || !changes) return []; + return changes.filter((ch) => ch.source?.includes(item.id)); + }, [item, changes]); + + const itemRelationships = useMemo(() => { + if (!item || !relationships) return []; + return relationships.filter( + (r) => r.config_id === item.id || r.related_id === item.id + ); + }, [item, relationships]); + + const itemAccess = useMemo(() => { + if (!item || !access) return []; + return access.filter((a) => matchesConfig(a, item)); + }, [item, access]); + + const itemAccessLogs = useMemo(() => { + if (!item || !accessLogs) return []; + return accessLogs.filter((a) => matchesConfig(a, item)); + }, [item, accessLogs]); + + if (!item) { + return ( +
+ Select a config item to view details +
+ ); + } + + const isOrphanedItem = + item.config_type === "Orphaned Changes" || item.id.startsWith("orphaned-"); + + return ( +
+
+ +
+
+

+ {item.name || item.id} +

+ + {!isOrphanedItem ? ( + + + + ) : ( + + + + )} +
+
+ {item.config_type} + {item.config_class && ({item.config_class})} + {item.status && ( + + {item.status} + + )} + {(item.Action === "inserted" || + (!item.Action && item.created_at)) && ( + + New + + )} + {item.Action === "updated" && ( + + Updated + + )} + {item.deleted_at && ( + + Deleted{item.delete_reason ? `: ${item.delete_reason}` : ""} + + )} +
+
+
+ +
+ ID: {item.id} +
+ + {/* Metadata: parents, location, timestamps */} +
+ {configMeta?.[item.id]?.parents && + configMeta[item.id].parents!.length > 0 && ( +
+ + {configMeta[item.id].parents!.join(" → ")} +
+ )} + {(configMeta?.[item.id]?.location || + (item.locations && item.locations.length > 0)) && ( +
+ + + {configMeta?.[item.id]?.location || item.locations!.join(", ")} + +
+ )} + {(item.created_at || item.last_modified) && ( +
+ {item.created_at && Created: {item.created_at}} + {item.last_modified && + item.last_modified !== "0001-01-01T00:00:00Z" && ( + Modified: {item.last_modified} + )} + {item.deleted_at && ( + Deleted: {item.deleted_at} + )} +
+ )} +
+ + + + + {item.aliases && item.aliases.length > 0 && ( +
+ +
+ )} + + {item.analysis && ( +
+
Analysis
+ +
+ )} + + {/* Relationships */} + {itemRelationships.length > 0 && ( +
+
+ {itemRelationships.map((rel, i) => { + const isOutgoing = rel.config_id === item.id; + const targetId = isOutgoing ? rel.related_id : rel.config_id; + const targetName = isOutgoing + ? rel.related_name || lookups.configs.get(targetId) || targetId + : rel.config_name || lookups.configs.get(targetId) || targetId; + const resolvedLabel = lookups.configs.get(targetId); + const targetType = resolvedLabel?.match(/\(([^)]+)\)$/)?.[1]; + return ( +
+ + {(targetType || rel.relation) && ( + + {targetType || rel.relation} + + )} + {targetName} + + {isOutgoing ? "outgoing" : "incoming"} + +
+ ); + })} +
+
+ )} + + {/* Changes */} + {itemChanges.length > 0 && ( +
+
+ {itemChanges.map((ch, i) => ( + + + {ch.change_type} + + {(ch.resolved?.action || ch.action) && ( + + {ch.resolved?.action || ch.action} + + )} + {ch.severity && ( + {ch.severity} + )} + {ch.summary && ( + + {ch.summary} + + )} + {ch.created_at && ( + + {ch.created_at} + + )} +
+ } + /> + ))} +
+ + )} + + {/* Config Access */} + {itemAccess.length > 0 && ( +
+
+ {itemAccess.map((a, i) => ( + + {(a.external_user_aliases?.length + ? a.external_user_aliases + : a.external_user_id + ? [a.external_user_id] + : [] + ).map((u, j) => ( + + ))} + {(a.external_role_aliases?.length + ? a.external_role_aliases + : a.external_role_id + ? [a.external_role_id] + : [] + ).map((r, j) => ( + + ))} + {(a.external_group_aliases?.length + ? a.external_group_aliases + : a.external_group_id + ? [a.external_group_id] + : [] + ).map((g, j) => ( + + ))} + {a.created_at && ( + + {a.created_at} + + )} +
+ } + /> + ))} +
+ + )} + + {/* Access Logs */} + {itemAccessLogs.length > 0 && ( +
+
+ {itemAccessLogs.map((a, i) => ( + + {a.external_user_aliases?.map((u, j) => ( + + ))} + {a.mfa !== undefined && ( + + MFA: {a.mfa ? "Yes" : "No"} + + )} + {a.count != null && ( + x{a.count} + )} + {a.created_at && ( + + {a.created_at} + + )} +
+ } + /> + ))} +
+ + )} + + {/* Config JSON */} + {item.config && ( +
+
+ {typeof item.config === "string" ? ( +
+                {item.config}
+              
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/EntityTable.tsx b/src/pages/config/settings/components/viewer/components/EntityTable.tsx new file mode 100644 index 0000000000..b95504048a --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/EntityTable.tsx @@ -0,0 +1,431 @@ +import { useState, useMemo, useCallback } from "react"; +import type { + ExternalConfigAccess, + ExternalConfigAccessLog, + ExternalUserGroup, + ExternalUser, + ExternalGroup +} from "../types"; +import { useSort, SortIcon } from "../hooks/useSort"; +import { + type Lookups, + resolveConfigId, + resolve, + matchesSearch +} from "../utils"; +import { AliasList } from "./AliasList"; + +interface Entity { + id: string; + name: string; + aliases?: string[]; + account_id?: string; + user_type?: string; +} + +interface Props { + title: string; + kind: "user" | "group" | "role"; + entities: Entity[]; + access?: ExternalConfigAccess[]; + accessLogs?: ExternalConfigAccessLog[]; + userGroups?: ExternalUserGroup[]; + allUsers?: ExternalUser[]; + allGroups?: ExternalGroup[]; + lookups: Lookups; + search?: string; + selectedId?: string; + onSelect?: (id: string | undefined) => void; +} + +function entityAliases(e: Entity): string[] { + return [e.name, ...(e.aliases || [])].filter(Boolean); +} + +function Section({ + title, + count, + children, + defaultOpen = true +}: { + title: string; + count?: number; + children: any; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+

setOpen(!open)} + > + {open ? "▼" : "▶"} + {title} + {count !== undefined && ` (${count})`} +

+ {open && children} +
+ ); +} + +function matchesEntity( + kind: string, + aliases: string[], + access: ExternalConfigAccess +): boolean { + const targets = + kind === "user" + ? access.external_user_aliases + : kind === "group" + ? access.external_group_aliases + : access.external_role_aliases; + if (targets?.some((t) => aliases.includes(t))) return true; + // Fall back to ID-based matching + const id = + kind === "user" + ? access.external_user_id + : kind === "group" + ? access.external_group_id + : access.external_role_id; + return !!id && aliases.includes(id); +} + +function matchesEntityLog( + aliases: string[], + log: ExternalConfigAccessLog +): boolean { + if (log.external_user_aliases?.some((t) => aliases.includes(t))) return true; + return !!log.external_user_id && aliases.includes(log.external_user_id); +} + +function columnsFor( + kind: "user" | "group" | "role" +): { key: string; label: string; cls: string }[] { + const base = [ + { key: "name", label: "Name", cls: "px-3 py-2" }, + { key: "account_id", label: "Account", cls: "px-3 py-2" } + ]; + if (kind === "role") + base.splice(1, 0, { key: "aliases", label: "Aliases", cls: "px-3 py-2" }); + if (kind === "user") + base.push({ key: "groups", label: "Groups", cls: "px-3 py-2" }); + if (kind === "group") + base.push({ key: "members", label: "Members", cls: "px-3 py-2" }); + return base; +} + +export function EntityTable({ + title, + kind, + entities, + access, + accessLogs, + userGroups, + allUsers, + allGroups, + lookups, + search, + selectedId, + onSelect +}: Props) { + const filtered = useMemo(() => { + if (!search) return entities; + return entities.filter((e) => + matchesSearch(search, e.name, ...(e.aliases || [])) + ); + }, [entities, search]); + const { sorted, sort, toggle } = useSort(filtered, "name"); + const cols = columnsFor(kind); + + // Resolve a v1.ExternalUserGroup to a (userId, groupId) pair using direct + // IDs when present, falling back to alias overlap against the entity lists + // we already have. This handles Azure DevOps memberships, which describe + // identities by descriptor alias rather than by Azure UUID. + const resolveMembership = useCallback( + (ug: ExternalUserGroup): { userId?: string; groupId?: string } => { + let userId = ug.external_user_id; + if (!userId && ug.external_user_aliases?.length && allUsers) { + const u = allUsers.find((x) => + ug.external_user_aliases!.some( + (a) => a === x.id || x.aliases?.includes(a) + ) + ); + if (u) userId = u.id; + } + let groupId = ug.external_group_id; + if (!groupId && ug.external_group_aliases?.length && allGroups) { + const g = allGroups.find((x) => + ug.external_group_aliases!.some( + (a) => a === x.id || x.aliases?.includes(a) + ) + ); + if (g) groupId = g.id; + } + return { userId, groupId }; + }, + [allUsers, allGroups] + ); + + const resolvedUserGroups = useMemo(() => { + if (!userGroups) return []; + return userGroups + .map(resolveMembership) + .filter((r) => r.userId && r.groupId) as { + userId: string; + groupId: string; + }[]; + }, [userGroups, resolveMembership]); + + // Count memberships per entity for list display + const membershipCounts = useMemo(() => { + const m: Record = {}; + for (const ug of resolvedUserGroups) { + if (kind === "user") m[ug.userId] = (m[ug.userId] || 0) + 1; + if (kind === "group") m[ug.groupId] = (m[ug.groupId] || 0) + 1; + } + return m; + }, [resolvedUserGroups, kind]); + + const selected = useMemo( + () => entities.find((e) => e.id === selectedId) || null, + [entities, selectedId] + ); + + const selectedAliases = useMemo( + () => (selected ? entityAliases(selected) : []), + [selected] + ); + + const relatedAccess = useMemo(() => { + if (!selected || !access) return []; + return access.filter((a) => matchesEntity(kind, selectedAliases, a)); + }, [selected, access, selectedAliases, kind]); + + const relatedLogs = useMemo(() => { + if (!selected || !accessLogs || kind !== "user") return []; + return accessLogs.filter((a) => matchesEntityLog(selectedAliases, a)); + }, [selected, accessLogs, selectedAliases, kind]); + + // For a user: find groups they belong to + const userMemberships = useMemo(() => { + if (!selected || kind !== "user" || !allGroups) return []; + const groupIds = new Set( + resolvedUserGroups + .filter((ug) => ug.userId === selected.id) + .map((ug) => ug.groupId) + ); + return allGroups.filter((g) => groupIds.has(g.id)); + }, [selected, kind, resolvedUserGroups, allGroups]); + + // For a group: find users that are members + const groupMembers = useMemo(() => { + if (!selected || kind !== "group" || !allUsers) return []; + const userIds = new Set( + resolvedUserGroups + .filter((ug) => ug.groupId === selected.id) + .map((ug) => ug.userId) + ); + return allUsers.filter((u) => userIds.has(u.id)); + }, [selected, kind, resolvedUserGroups, allUsers]); + + if (!entities || entities.length === 0) { + return ( +
+ No {title.toLowerCase()} found +
+ ); + } + + return ( +
+ {/* Entity list */} +
+ + + + {cols.map((c) => ( + + ))} + + + + {sorted.map((e, idx) => ( + + onSelect?.(selectedId === e.id ? undefined : e.id) + } + > + + {kind === "role" && ( + + )} + + {(kind === "user" || kind === "group") && ( + + )} + + ))} + +
toggle(c.key)} + > + {c.label} + +
{e.name} + + + {e.account_id || ""} + + {membershipCounts[e.id] ? ( + + {membershipCounts[e.id]} + + ) : ( + 0 + )} +
+
+ + {/* Detail pane */} +
+ {!selected ? ( +
+ Select a {kind} to view access details +
+ ) : ( +
+
+

+ {selected.name} +

+
+ {selected.id} +
+
+ + {selected.aliases && selected.aliases.length > 0 && ( +
+ +
+ )} + + {kind === "user" && userMemberships.length > 0 && ( +
+
+ {userMemberships.map((g) => ( + + {g.name || g.id} + + ))} +
+
+ )} + + {kind === "group" && groupMembers.length > 0 && ( +
+
+ {groupMembers.map((u) => ( + + {u.name || u.id} + + ))} +
+
+ )} + + {relatedAccess.length > 0 && ( +
+
+ {relatedAccess.map((a, i) => ( +
+
+ {resolveConfigId(lookups, a.external_config_id)} +
+
+ {(a.external_role_aliases?.length + ? a.external_role_aliases + : a.external_role_id + ? [a.external_role_id] + : [] + ).map((r, j) => ( + + {resolve(lookups.roles, r)} + + ))} +
+ {a.created_at && ( + {a.created_at} + )} +
+ ))} +
+
+ )} + + {relatedLogs.length > 0 && ( +
+
+ {relatedLogs.map((a, i) => ( +
+ + {resolveConfigId(lookups, a.external_config_id)} + + {a.mfa !== undefined && ( + + MFA: {a.mfa ? "Yes" : "No"} + + )} + {a.count != null && ( + x{a.count} + )} + {a.created_at && ( + + {a.created_at} + + )} +
+ ))} +
+
+ )} + + {relatedAccess.length === 0 && + relatedLogs.length === 0 && + userMemberships.length === 0 && + groupMembers.length === 0 && ( +
+ No access records for this {kind} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/FilterBar.tsx b/src/pages/config/settings/components/viewer/components/FilterBar.tsx new file mode 100644 index 0000000000..9e0da5c5bf --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/FilterBar.tsx @@ -0,0 +1,90 @@ +export interface Filters { + health: Set; + type: Set; +} + +interface Props { + filters: Filters; + onChange: (f: Filters) => void; + healthValues: string[]; + typeValues: string[]; +} + +function toggle(set: Set, val: string): Set { + const next = new Set(set); + if (next.has(val)) next.delete(val); + else next.add(val); + return next; +} + +const HEALTH_COLORS: Record = { + healthy: "bg-green-100 text-green-700 border-green-300", + unhealthy: "bg-red-100 text-red-700 border-red-300", + warning: "bg-yellow-100 text-yellow-700 border-yellow-300", + unknown: "bg-gray-100 text-gray-600 border-gray-300" +}; + +export function FilterBar({ + filters, + onChange, + healthValues, + typeValues +}: Props) { + if (healthValues.length === 0 && typeValues.length === 0) return null; + + return ( +
+ {healthValues.map((h) => { + const active = filters.health.has(h); + const colors = HEALTH_COLORS[h] || HEALTH_COLORS["unknown"]; + return ( + + ); + })} + {healthValues.length > 0 && typeValues.length > 0 && ( + | + )} + {typeValues.map((t) => { + const active = filters.type.has(t); + return ( + + ); + })} + {(filters.health.size > 0 || filters.type.size > 0) && ( + + )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/HARPanel.tsx b/src/pages/config/settings/components/viewer/components/HARPanel.tsx new file mode 100644 index 0000000000..8da695ef2c --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/HARPanel.tsx @@ -0,0 +1,202 @@ +import { useState, useMemo } from "react"; +import type { HAREntry } from "../types"; +import { statusColor, matchesSearch } from "../utils"; +import { useSort, SortIcon } from "../hooks/useSort"; +import { JsonView } from "./JsonView"; + +interface Props { + entries: HAREntry[]; + search?: string; +} + +function tryParseJson(text: string): any | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function isJsonType(mime?: string): boolean { + return !!mime && (mime.includes("json") || mime.includes("javascript")); +} + +function BodyView({ text, mimeType }: { text: string; mimeType?: string }) { + if (isJsonType(mimeType)) { + const parsed = tryParseJson(text); + if (parsed !== null) return ; + } + return
{text}
; +} + +function HARRow({ entry }: { entry: HAREntry }) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(!open)} + > + + {entry.request.method} + + + {entry.request.url} + + + {entry.response.status} + + + {entry.time.toFixed(0)}ms + + + {formatBytes(entry.response.bodySize)} + + + {entry.response.content?.mimeType || ""} + + + {open && ( + + +
+
+
+ Request Headers +
+
+ {entry.request.headers?.map((h, i) => ( +
+ {h.name}:{" "} + {h.value} +
+ ))} +
+ {entry.request.postData?.text && ( +
+
+ Request Body +
+
+ +
+
+ )} +
+
+
+ Response Headers +
+
+ {entry.response.headers?.map((h, i) => ( +
+ {h.name}:{" "} + {h.value} +
+ ))} +
+
+
+ {entry.response.content?.text && ( +
+
+ Response Body +
+
+ +
+
+ )} + + + )} + + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 0) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +const COLS: { key: string; label: string; cls: string }[] = [ + { key: "request.method", label: "Method", cls: "px-2 py-2 w-16" }, + { key: "request.url", label: "URL", cls: "px-2 py-2" }, + { key: "response.status", label: "Status", cls: "px-2 py-2 w-20" }, + { key: "time", label: "Time", cls: "px-2 py-2 w-16 text-right" }, + { key: "response.bodySize", label: "Size", cls: "px-2 py-2 w-16 text-right" }, + { key: "response.content.mimeType", label: "Type", cls: "px-2 py-2 w-40" } +]; + +export function HARPanel({ entries, search }: Props) { + const filtered = useMemo(() => { + if (!search) return entries; + return entries.filter((e) => + matchesSearch( + search, + e.request.url, + e.request.method, + e.request.postData?.text, + e.response.content?.text + ) + ); + }, [entries, search]); + const { sorted, sort, toggle } = useSort(filtered, "time"); + + if (!entries || entries.length === 0) { + return ( +
+ No HTTP traffic captured +
+ ); + } + + return ( +
+ + + + {COLS.map((c) => ( + + ))} + + + + {sorted.map((e) => ( + + ))} + +
toggle(c.key)} + > + {c.label} + +
+
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/JsonView.tsx b/src/pages/config/settings/components/viewer/components/JsonView.tsx new file mode 100644 index 0000000000..604eea46ad --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/JsonView.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; + +interface Props { + data: any; + name?: string; + depth?: number; +} + +export function JsonView({ data, name, depth = 0 }: Props) { + const [open, setOpen] = useState(depth < 2); + + if (data === null || data === undefined) { + return null; + } + + if (typeof data === "string") { + return "{data}"; + } + + if (typeof data === "number" || typeof data === "boolean") { + return {String(data)}; + } + + const isArray = Array.isArray(data); + const entries: [string | number, any][] = isArray + ? data.map((v: any, i: number) => [i, v]) + : (Object.entries(data) as [string, any][]); + const bracket = isArray ? ["[", "]"] : ["{", "}"]; + + if (entries.length === 0) { + return ( + + {bracket[0]} + {bracket[1]} + + ); + } + + return ( +
0 ? "12px" : "0" }} + > + + {open && ( + <> + {entries.map(([key, val]) => ( +
+ {typeof val === "object" && val !== null ? ( + + ) : ( +
+ + {isArray ? "" : String(key)} + + {!isArray && : } + +
+ )} +
+ ))} + {bracket[1]} + + )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/ScrapeConfigPanel.tsx b/src/pages/config/settings/components/viewer/components/ScrapeConfigPanel.tsx new file mode 100644 index 0000000000..9dd659cac7 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/ScrapeConfigPanel.tsx @@ -0,0 +1,199 @@ +import { useState, useMemo } from "react"; +import { JsonView } from "./JsonView"; +import type { PropertyInfo, LogLevelInfo } from "../types"; + +interface Props { + spec: any; + properties?: Record; + logLevel?: LogLevelInfo; +} + +function formatDecimal(value: number): string { + return value + .toFixed(2) + .replace(/\.0+$/, "") + .replace(/(\.\d*[1-9])0+$/, "$1"); +} + +function formatValue(val: any, type?: string): string { + if (val === null || val === undefined) return ""; + if (type === "duration" && typeof val === "number") { + // Go's time.Duration serializes as nanoseconds + const ms = val / 1e6; + if (ms < 1000) return `${formatDecimal(ms)}ms`; + const secs = ms / 1000; + if (secs < 60) return `${formatDecimal(secs)}s`; + const mins = secs / 60; + if (mins < 60) return `${formatDecimal(mins)}m`; + return `${formatDecimal(mins / 60)}h`; + } + if (type === "bool") return val ? "on" : "off"; + return String(val); +} + +function isOverridden(prop: PropertyInfo): boolean { + if (prop.value === null || prop.value === undefined) return false; + return String(prop.value) !== String(prop.default); +} + +const typeBadgeColors: Record = { + bool: "bg-purple-100 text-purple-700", + int: "bg-blue-100 text-blue-700", + duration: "bg-teal-100 text-teal-700", + string: "bg-gray-100 text-gray-600" +}; + +export function ScrapeConfigPanel({ spec, properties, logLevel }: Props) { + const [propFilter, setPropFilter] = useState(""); + + const sortedProps = useMemo(() => { + if (!properties) return []; + return Object.entries(properties) + .map(([key, info]) => ({ key, ...info })) + .sort((a, b) => a.key.localeCompare(b.key)); + }, [properties]); + + const filteredProps = useMemo(() => { + if (!propFilter) return sortedProps; + const q = propFilter.toLowerCase(); + return sortedProps.filter( + (p) => + p.key.toLowerCase().includes(q) || + formatValue(p.value, p.type).toLowerCase().includes(q) + ); + }, [sortedProps, propFilter]); + + const hasLogLevel = !!(logLevel?.scraper || logLevel?.global); + const hasContent = !!spec || sortedProps.length > 0 || hasLogLevel; + if (!hasContent) { + return ( +
+ No scrape configuration available +
+ ); + } + + return ( +
+ {/* Log Levels */} + {hasLogLevel && ( +
+

+ Log Level +

+
+ {logLevel.scraper && ( + + + Scraper: {logLevel.scraper} + + )} + {logLevel.global && ( + + + Global: {logLevel.global} + + )} +
+
+ )} + + {/* Properties Table */} + {sortedProps.length > 0 && ( +
+
+

+ Properties + + ({sortedProps.length}) + +

+
+ + + setPropFilter((e.target as HTMLInputElement).value) + } + className="w-48 rounded border border-gray-300 py-1 pl-6 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+
+ + + + + + + + + + + {filteredProps.map((prop) => { + const overridden = isOverridden(prop); + return ( + + + + + + + ); + })} + {filteredProps.length === 0 && ( + + + + )} + +
KeyValueDefaultType
+ {prop.key} + + {formatValue(prop.value, prop.type) || ( + + )} + + {formatValue(prop.default, prop.type)} + + {prop.type && ( + + {prop.type} + + )} +
+ No matching properties +
+
+
+ )} + + {/* Scrape Configuration */} + {spec && ( +
+

+ Scrape Configuration +

+
+ +
+
+ )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/ScraperList.tsx b/src/pages/config/settings/components/viewer/components/ScraperList.tsx new file mode 100644 index 0000000000..5f4681e5c6 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/ScraperList.tsx @@ -0,0 +1,68 @@ +import type { ScraperProgress } from "../types"; + +interface Props { + scrapers: ScraperProgress[]; +} + +function statusIcon(status: ScraperProgress["status"]): string { + switch (status) { + case "pending": + return "codicon:circle-outline"; + case "running": + return "svg-spinners:ring-resize"; + case "complete": + return "codicon:pass-filled"; + case "error": + return "codicon:error"; + default: + return "codicon:question"; + } +} + +function statusColor(status: ScraperProgress["status"]): string { + switch (status) { + case "pending": + return "text-gray-400"; + case "running": + return "text-blue-500"; + case "complete": + return "text-green-500"; + case "error": + return "text-red-500"; + default: + return "text-gray-400"; + } +} + +export function ScraperList({ scrapers }: Props) { + if (!scrapers || scrapers.length === 0) return null; + + return ( +
+ {scrapers.map((s) => ( +
+ + + + + {s.name} + + {s.result_count > 0 && ( + ({s.result_count}) + )} + {(s.duration_secs ?? 0) > 0 && ( + + {(s.duration_secs as number).toFixed(1)}s + + )} +
+ ))} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/SnapshotPanel.tsx b/src/pages/config/settings/components/viewer/components/SnapshotPanel.tsx new file mode 100644 index 0000000000..c689b27b1a --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/SnapshotPanel.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + EntityWindowCounts, + ScrapeSnapshot, + ScrapeSnapshotDiff, + ScrapeSnapshotPair +} from "../types"; + +interface Props { + pairs?: Record; +} + +type View = "diff" | "after" | "before"; + +interface RowData { + key: string; + label: string; + counts: EntityWindowCounts; +} + +interface SectionData { + title: string; + rows: RowData[]; +} + +const ZERO: EntityWindowCounts = { + total: 0, + updated_last: 0, + updated_hour: 0, + updated_day: 0, + updated_week: 0, + deleted_last: 0, + deleted_hour: 0, + deleted_day: 0, + deleted_week: 0 +}; + +const ENTITY_ROWS: { + key: keyof ScrapeSnapshot & keyof ScrapeSnapshotDiff; + label: string; +}[] = [ + { key: "external_users", label: "External Users" }, + { key: "external_groups", label: "External Groups" }, + { key: "external_roles", label: "External Roles" }, + { key: "external_user_groups", label: "External User Groups" }, + { key: "config_access", label: "Config Access" }, + { key: "config_access_logs", label: "Access Logs" } +]; + +const VIEW_ORDER: View[] = ["after", "diff", "before"]; + +function isZero(c?: EntityWindowCounts): boolean { + if (!c) return true; + return ( + c.total === 0 && + c.updated_last === 0 && + c.updated_hour === 0 && + c.updated_day === 0 && + c.updated_week === 0 && + c.deleted_last === 0 && + c.deleted_hour === 0 && + c.deleted_day === 0 && + c.deleted_week === 0 && + !c.last_created_at && + !c.last_updated_at + ); +} + +function formatTime(ts?: string): string { + if (!ts) return ""; + return new Date(ts).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +} + +function asSigned(n: number): string { + if (n > 0) return `+${n}`; + return `${n}`; +} + +function valueClassName(value: number, isDiff: boolean): string { + if (!isDiff) return "text-gray-700"; + if (value > 0) return "font-medium text-green-600"; + if (value < 0) return "font-medium text-red-600"; + return "text-gray-400"; +} + +function formatValue(value: number, isDiff: boolean): string { + if (value === 0) return ""; + return isDiff ? asSigned(value) : String(value); +} + +function buildSections( + data: ScrapeSnapshot | ScrapeSnapshotDiff | undefined +): SectionData[] { + if (!data) return []; + + const perScraperRows: RowData[] = Object.entries(data.per_scraper || {}) + .sort(([a], [b]) => a.localeCompare(b)) + .filter(([, counts]) => !isZero(counts)) + .map(([name, counts]) => ({ + key: `scraper:${name}`, + label: name, + counts + })); + + const perTypeRows: RowData[] = Object.entries(data.per_config_type || {}) + .sort(([a], [b]) => a.localeCompare(b)) + .filter(([, counts]) => !isZero(counts)) + .map(([name, counts]) => ({ + key: `type:${name}`, + label: name, + counts + })); + + const externalRows: RowData[] = ENTITY_ROWS.map(({ key, label }) => ({ + key: `entity:${String(key)}`, + label, + counts: ((data as any)[key] as EntityWindowCounts) || ZERO + })).filter((row) => !isZero(row.counts)); + + return [ + { title: "Per Scraper", rows: perScraperRows }, + { title: "Per Config Type", rows: perTypeRows }, + { title: "External Entities", rows: externalRows } + ]; +} + +function CountsRow({ + label, + counts, + isDiff +}: { + label: string; + counts: EntityWindowCounts; + isDiff: boolean; +}) { + return ( + + {label} + + {formatValue(counts.total, isDiff)} + + + {formatValue(counts.updated_last, isDiff)} + + + {formatValue(counts.updated_hour, isDiff)} + + + {formatValue(counts.updated_day, isDiff)} + + + {formatValue(counts.updated_week, isDiff)} + + + {formatValue(counts.deleted_last, isDiff)} + + + {formatValue(counts.deleted_hour, isDiff)} + + + {formatValue(counts.deleted_day, isDiff)} + + + {formatValue(counts.deleted_week, isDiff)} + + + {formatTime(counts.last_created_at)} + + + {formatTime(counts.last_updated_at)} + + + ); +} + +function CountsTable({ + title, + rows, + isDiff +}: { + title: string; + rows: RowData[]; + isDiff: boolean; +}) { + if (rows.length === 0) { + return null; + } + + return ( +
+

+ {title} +

+ + + + + + + + + + + + + {rows.map((row) => ( + + ))} + +
Total + Updated (L / H / D / W) + + Deleted (L / H / D / W) + Last CreatedLast Updated
+
+ ); +} + +function SnapshotViewHeader({ + scraperNames, + activeScraper, + onScraperChange, + view, + onViewChange, + hasAfter, + hasBefore, + hasDiff, + runStartedAt +}: { + scraperNames: string[]; + activeScraper: string; + onScraperChange: (scraper: string) => void; + view: View; + onViewChange: (view: View) => void; + hasAfter: boolean; + hasBefore: boolean; + hasDiff: boolean; + runStartedAt?: string; +}) { + return ( +
+ {scraperNames.length > 1 && ( + + )} + +
+ {VIEW_ORDER.map((candidate) => { + const disabled = + (candidate === "after" && !hasAfter) || + (candidate === "before" && !hasBefore) || + (candidate === "diff" && !hasDiff); + + return ( + + ); + })} +
+ + {runStartedAt && ( +
+ run started at {new Date(runStartedAt).toLocaleString()} + {!hasAfter && hasBefore && ( + + (scrape failed — showing pre-scrape state) + + )} +
+ )} +
+ ); +} + +export function SnapshotPanel({ pairs }: Props) { + const scraperNames = useMemo( + () => (pairs ? Object.keys(pairs).sort() : []), + [pairs] + ); + + const [selectedScraper, setSelectedScraper] = useState(null); + const [userView, setUserView] = useState(null); + + useEffect(() => { + if (scraperNames.length === 0) { + setSelectedScraper(null); + setUserView(null); + return; + } + + if (!selectedScraper || !scraperNames.includes(selectedScraper)) { + setSelectedScraper(scraperNames[0]); + } + }, [scraperNames, selectedScraper]); + + const activeScraper = selectedScraper ?? scraperNames[0] ?? null; + const pair = activeScraper && pairs ? pairs[activeScraper] : undefined; + + const defaultView: View = pair?.after + ? "after" + : pair?.before + ? "before" + : "diff"; + + const view: View = userView ?? defaultView; + + useEffect(() => { + if (!userView) return; + if ( + (userView === "after" && !pair?.after) || + (userView === "before" && !pair?.before) || + (userView === "diff" && !pair?.diff) + ) { + setUserView(null); + } + }, [pair?.after, pair?.before, pair?.diff, userView]); + + const data = + pair && + (view === "diff" ? pair.diff : view === "after" ? pair.after : pair.before); + + const sections = useMemo(() => buildSections(data), [data]); + + if (!pairs || scraperNames.length === 0) { + return ( +
+ No scrape snapshot captured for this run. Snapshots are only captured + when running with a database connection. +
+ ); + } + + const isDiffView = view === "diff"; + const isEmpty = sections.every((section) => section.rows.length === 0); + + return ( +
+ + + {!data ? ( +
No data
+ ) : isDiffView && isEmpty ? ( +
+ No changes between before and after snapshots. +
+ ) : ( + sections.map((section) => ( + + )) + )} +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/SplitPane.tsx b/src/pages/config/settings/components/viewer/components/SplitPane.tsx new file mode 100644 index 0000000000..1f63dbcf12 --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/SplitPane.tsx @@ -0,0 +1,86 @@ +import { + useState, + useRef, + useCallback, + useEffect, + type ReactNode, + type MouseEvent as ReactMouseEvent +} from "react"; + +interface Props { + left: ReactNode; + right: ReactNode; + defaultSplit?: number; + minLeft?: number; + minRight?: number; +} + +export function SplitPane({ + left, + right, + defaultSplit = 50, + minLeft = 20, + minRight = 20 +}: Props) { + const [split, setSplit] = useState(defaultSplit); + const dragging = useRef(false); + const container = useRef(null); + const moveHandlerRef = useRef<((e: MouseEvent) => void) | null>(null); + const upHandlerRef = useRef<(() => void) | null>(null); + + const cleanupDrag = useCallback(() => { + const onMove = moveHandlerRef.current; + const onUp = upHandlerRef.current; + if (onMove) document.removeEventListener("mousemove", onMove); + if (onUp) document.removeEventListener("mouseup", onUp); + moveHandlerRef.current = null; + upHandlerRef.current = null; + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + const onMouseDown = useCallback( + (e: ReactMouseEvent) => { + e.preventDefault(); + cleanupDrag(); + dragging.current = true; + + const onMove = (e: MouseEvent) => { + if (!dragging.current || !container.current) return; + const rect = container.current.getBoundingClientRect(); + const pct = ((e.clientX - rect.left) / rect.width) * 100; + setSplit(Math.max(minLeft, Math.min(100 - minRight, pct))); + }; + + const onUp = () => { + cleanupDrag(); + }; + + moveHandlerRef.current = onMove; + upHandlerRef.current = onUp; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [cleanupDrag, minLeft, minRight] + ); + + useEffect(() => cleanupDrag, [cleanupDrag]); + + return ( +
+
+ {left} +
+
+
+ {right} +
+
+ ); +} diff --git a/src/pages/config/settings/components/viewer/components/Summary.tsx b/src/pages/config/settings/components/viewer/components/Summary.tsx new file mode 100644 index 0000000000..edd8c6a82d --- /dev/null +++ b/src/pages/config/settings/components/viewer/components/Summary.tsx @@ -0,0 +1,103 @@ +import type { Counts, SaveSummary } from "../types"; +import { formatDuration } from "../utils"; + +interface Props { + counts: Counts; + saveSummary?: SaveSummary; + startedAt: number; + done: boolean; + elapsed: number; +} + +function Badge({ + label, + count, + color +}: { + label: string; + count: number; + color: string; +}) { + if (count === 0) return null; + return ( + + {count} {label} + + ); +} + +export function Summary({ counts, saveSummary, done, elapsed }: Props) { + return ( +
+ + + + + + + {saveSummary && + saveSummary.config_types && + (() => { + let added = 0, + updated = 0, + unchanged = 0; + for (const v of Object.values(saveSummary.config_types)) { + added += v.added; + updated += v.updated; + unchanged += v.unchanged; + } + return ( + <> + {added > 0 && ( + + )} + {updated > 0 && ( + + )} + {unchanged > 0 && ( + + )} + + ); + })()} + + + {done ? "done" : "running"} {formatDuration(elapsed)} + +
+ ); +} diff --git a/src/pages/config/settings/components/viewer/globals.d.ts b/src/pages/config/settings/components/viewer/globals.d.ts new file mode 100644 index 0000000000..f0ee66eae0 --- /dev/null +++ b/src/pages/config/settings/components/viewer/globals.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace JSX { + interface IntrinsicElements { + "iconify-icon": any; + } + } +} + +export {}; diff --git a/src/pages/config/settings/components/viewer/hooks/useRoute.ts b/src/pages/config/settings/components/viewer/hooks/useRoute.ts new file mode 100644 index 0000000000..5285ef3add --- /dev/null +++ b/src/pages/config/settings/components/viewer/hooks/useRoute.ts @@ -0,0 +1,104 @@ +import { useState, useEffect, useCallback } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import type { Tab } from "../types"; + +export interface Route { + tab: Tab; + id?: string; + q?: string; +} + +const VALID_TABS: Tab[] = [ + "configs", + "logs", + "har", + "users", + "groups", + "roles", + "access", + "access_logs", + "issues", + "snapshot", + "last_summary", + "spec" +]; + +const DEFAULT_TAB: Tab = "spec"; + +const SEARCH_TAB_KEY = "scrapeTab"; +const SEARCH_ID_KEY = "scrapeId"; +const SEARCH_Q_KEY = "scrapeQ"; + +function readSearchRoute(search: string): Route { + const params = new URLSearchParams(search); + const tab = params.get(SEARCH_TAB_KEY); + return { + tab: VALID_TABS.includes(tab as Tab) ? (tab as Tab) : DEFAULT_TAB, + id: params.get(SEARCH_ID_KEY) || undefined, + q: params.get(SEARCH_Q_KEY) || undefined + }; +} + +function buildSearch(route: Route, search: string): string { + const params = new URLSearchParams(search); + params.set(SEARCH_TAB_KEY, route.tab); + + if (route.id) { + params.set(SEARCH_ID_KEY, route.id); + } else { + params.delete(SEARCH_ID_KEY); + } + + if (route.q) { + params.set(SEARCH_Q_KEY, route.q); + } else { + params.delete(SEARCH_Q_KEY); + } + + const value = params.toString(); + return value ? `?${value}` : ""; +} + +export function useRoute(options?: { + syncWithURL?: boolean; +}): [Route, (next: Partial) => void] { + const syncWithURL = options?.syncWithURL ?? true; + + const location = useLocation(); + const navigateURL = useNavigate(); + + const [route, setRoute] = useState(() => { + if (!syncWithURL) { + return { tab: DEFAULT_TAB }; + } + + return readSearchRoute(location.search); + }); + + useEffect(() => { + if (!syncWithURL) return; + setRoute(readSearchRoute(location.search)); + }, [syncWithURL, location.search]); + + const navigate = useCallback( + (next: Partial) => { + const merged: Route = { + tab: next.tab ?? route.tab, + id: "id" in next ? next.id : route.id, + q: "q" in next ? next.q : route.q + }; + + setRoute(merged); + + if (syncWithURL) { + const search = buildSearch(merged, location.search); + if (location.search !== search) { + navigateURL(`${location.pathname}${search}`, { replace: true }); + } + } + }, + [route, syncWithURL, location.pathname, location.search, navigateURL] + ); + + return [route, navigate]; +} diff --git a/src/pages/config/settings/components/viewer/hooks/useSort.tsx b/src/pages/config/settings/components/viewer/hooks/useSort.tsx new file mode 100644 index 0000000000..a48676aacf --- /dev/null +++ b/src/pages/config/settings/components/viewer/hooks/useSort.tsx @@ -0,0 +1,54 @@ +import { useState, useMemo } from "react"; + +export type SortDir = "asc" | "desc"; + +export interface SortState { + key: string; + dir: SortDir; +} + +export function useSort(items: T[], defaultKey?: string) { + const [sort, setSort] = useState( + defaultKey ? { key: defaultKey, dir: "asc" } : null + ); + + function toggle(key: string) { + setSort((prev) => { + if (prev?.key === key) { + return prev.dir === "asc" ? { key, dir: "desc" } : null; + } + return { key, dir: "asc" }; + }); + } + + const sorted = useMemo(() => { + if (!items) return []; + if (!sort) return items; + const { key, dir } = sort; + return [...items].sort((a, b) => { + const av = resolve(a, key); + const bv = resolve(b, key); + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + const cmp = + typeof av === "number" && typeof bv === "number" + ? av - bv + : String(av).localeCompare(String(bv)); + return dir === "asc" ? cmp : -cmp; + }); + }, [items, sort]); + + return { sorted, sort, toggle }; +} + +function resolve(obj: any, path: string): any { + return path.split(".").reduce((o, k) => o?.[k], obj); +} + +export function SortIcon({ active, dir }: { active: boolean; dir?: SortDir }) { + if (!active) return ; + return ( + {dir === "asc" ? "↑" : "↓"} + ); +} diff --git a/src/pages/config/settings/components/viewer/types.ts b/src/pages/config/settings/components/viewer/types.ts new file mode 100644 index 0000000000..7f33faef67 --- /dev/null +++ b/src/pages/config/settings/components/viewer/types.ts @@ -0,0 +1,330 @@ +export interface ScraperProgress { + name: string; + status: "pending" | "running" | "complete" | "error"; + started_at?: string; + duration_secs?: number; + error?: string; + result_count: number; +} + +export interface ScrapeResult { + id: string; + name: string; + config_type: string; + config_class?: string; + status?: string; + health?: string; + icon?: string; + labels?: Record; + tags?: Record; + config?: any; + analysis?: any; + properties?: any[]; + description?: string; + source?: string; + aliases?: string[]; + locations?: string[]; + parents?: string[]; + created_at?: string; + deleted_at?: string; + delete_reason?: string; + last_modified?: string; + Action?: string; // "inserted" | "updated" | "unchanged" — uppercase key from Go json tag +} + +export interface ConfigChange { + change_type: string; + action?: string; + severity?: string; + source?: string; + summary?: string; + external_id?: string; + config_type?: string; + diff?: string; + patches?: string; + created_at?: string; + external_created_by?: string; + resolved?: { + action?: string; + config_id?: string; + change_type?: string; + summary?: string; + severity?: string; + }; +} + +export interface UIRelationship { + config_id: string; + related_id: string; + relation: string; + config_name?: string; + related_name?: string; +} + +export interface ConfigAnalysis { + analyzer: string; + message: string; + severity: string; + analysis_type: string; + summary?: string; + status?: string; +} + +export interface ExternalUser { + id: string; + name: string; + aliases?: string[]; + account_id?: string; + user_type?: string; +} + +export interface ExternalGroup { + id: string; + name: string; + aliases?: string[]; + account_id?: string; +} + +export interface ExternalRole { + id: string; + name: string; + aliases?: string[]; +} + +export interface ExternalUserGroup { + external_user_id?: string; + external_group_id?: string; + external_user_aliases?: string[]; + external_group_aliases?: string[]; +} + +export interface ExternalConfigAccess { + id: string; + config_id?: string; + external_config_id?: any; + application_id?: string; + scraper_id?: string; + source?: string; + external_user_id?: string; + external_role_id?: string; + external_group_id?: string; + external_user_aliases?: string[]; + external_role_aliases?: string[]; + external_group_aliases?: string[]; + created_at?: string; + created_by?: string; + deleted_at?: string; + deleted_by?: string; + last_reviewed_at?: string; + last_reviewed_by?: string; + [key: string]: any; +} + +export interface ExternalConfigAccessLog { + config_id?: string; + external_config_id?: any; + external_user_id?: string; + external_user_aliases?: string[]; + mfa?: boolean; + count?: number; + created_at?: string; + properties?: Record; + [key: string]: any; +} + +export interface FullScrapeResults { + configs?: ScrapeResult[]; + changes?: ConfigChange[]; + analysis?: ConfigAnalysis[]; + external_users?: ExternalUser[]; + external_groups?: ExternalGroup[]; + external_roles?: ExternalRole[]; + external_user_groups?: ExternalUserGroup[]; + config_access?: ExternalConfigAccess[]; + config_access_logs?: ExternalConfigAccessLog[]; +} + +// HAR types matching github.com/flanksource/commons/har +export interface HAREntry { + startedDateTime: string; + time: number; + request: HARRequest; + response: HARResponse; + cache: any; + timings: { send: number; wait: number; receive: number }; +} + +export interface HARRequest { + method: string; + url: string; + httpVersion: string; + headers: { name: string; value: string }[]; + queryString: { name: string; value: string }[]; + postData?: { mimeType: string; text: string }; + headersSize: number; + bodySize: number; +} + +export interface HARResponse { + status: number; + statusText: string; + httpVersion: string; + headers: { name: string; value: string }[]; + content: { + size: number; + mimeType?: string; + text?: string; + truncated?: boolean; + }; + redirectURL: string; + headersSize: number; + bodySize: number; +} + +export interface Counts { + configs: number; + changes: number; + analysis: number; + relationships: number; + external_users: number; + external_groups: number; + external_roles: number; + config_access: number; + access_logs: number; + errors: number; +} + +export interface SaveSummary { + config_types?: Record< + string, + { added: number; updated: number; unchanged: number; changes: number } + >; +} + +export interface ConfigMeta { + parents?: string[]; + location?: string; +} + +export interface Warning { + input?: any; + output?: any; + result?: any; + expr?: string; + error?: string; + count?: number; +} + +export interface ScrapeIssue { + type: string; + message?: string; + change?: ConfigChange; + warning?: Warning; +} + +export interface EntityWindowCounts { + total: number; + updated_last: number; + updated_hour: number; + updated_day: number; + updated_week: number; + deleted_last: number; + deleted_hour: number; + deleted_day: number; + deleted_week: number; + last_created_at?: string; + last_updated_at?: string; +} + +export interface ScrapeSnapshot { + captured_at: string; + run_started_at: string; + per_scraper: Record; + per_config_type: Record; + external_users: EntityWindowCounts; + external_groups: EntityWindowCounts; + external_roles: EntityWindowCounts; + external_user_groups: EntityWindowCounts; + config_access: EntityWindowCounts; + config_access_logs: EntityWindowCounts; +} + +export interface ScrapeSnapshotDiff { + per_scraper?: Record; + per_config_type?: Record; + external_users: EntityWindowCounts; + external_groups: EntityWindowCounts; + external_roles: EntityWindowCounts; + external_user_groups: EntityWindowCounts; + config_access: EntityWindowCounts; + config_access_logs: EntityWindowCounts; +} + +export interface ScrapeSnapshotPair { + before?: ScrapeSnapshot; + after?: ScrapeSnapshot; + diff: ScrapeSnapshotDiff; +} + +export interface PropertyInfo { + value?: any; + default?: any; + type?: string; +} + +export interface LogLevelInfo { + scraper?: string; + global?: string; +} + +export interface BuildInfo { + version: string; + commit: string; + date: string; +} + +export interface Snapshot { + scrapers: ScraperProgress[]; + results: FullScrapeResults; + relationships?: UIRelationship[]; + config_meta?: Record; + issues?: ScrapeIssue[]; + counts: Counts; + save_summary?: SaveSummary; + snapshots?: Record; + scrape_spec?: any; + properties?: Record; + log_level?: LogLevelInfo; + har?: HAREntry[]; + logs: string; + done: boolean; + started_at: number; + build_info?: BuildInfo; + last_scrape_summary?: any; +} + +export interface TypeGroup { + type: string; + items: ScrapeResult[]; + counts: { + healthy: number; + unhealthy: number; + warning: number; + unknown: number; + errors: number; + }; +} + +export type Tab = + | "configs" + | "logs" + | "har" + | "users" + | "groups" + | "roles" + | "access" + | "access_logs" + | "issues" + | "snapshot" + | "last_summary" + | "spec"; diff --git a/src/pages/config/settings/components/viewer/utils.ts b/src/pages/config/settings/components/viewer/utils.ts new file mode 100644 index 0000000000..bf7d376b13 --- /dev/null +++ b/src/pages/config/settings/components/viewer/utils.ts @@ -0,0 +1,316 @@ +import type { + ScrapeResult, + TypeGroup, + FullScrapeResults, + ExternalConfigAccess, + ExternalConfigAccessLog +} from "./types"; + +export function groupByType(items: ScrapeResult[]): TypeGroup[] { + const groups = new Map(); + for (const item of items) { + const key = item.config_type || "Unknown"; + const list = groups.get(key) || []; + list.push(item); + groups.set(key, list); + } + + return Array.from(groups.entries()) + .map(([type, items]) => ({ + type, + items, + counts: countHealth(items) + })) + .sort((a, b) => a.type.localeCompare(b.type)); +} + +export function countHealth(items: ScrapeResult[]) { + const c = { healthy: 0, unhealthy: 0, warning: 0, unknown: 0, errors: 0 }; + for (const item of items) { + switch (item.health) { + case "healthy": + c.healthy++; + break; + case "unhealthy": + c.unhealthy++; + break; + case "warning": + c.warning++; + break; + default: + c.unknown++; + break; + } + } + return c; +} + +export function healthIcon(health?: string): string { + switch (health) { + case "healthy": + return "codicon:pass-filled"; + case "unhealthy": + return "codicon:error"; + case "warning": + return "codicon:warning"; + default: + return "codicon:circle-outline"; + } +} + +export function healthColor(health?: string): string { + switch (health) { + case "healthy": + return "text-green-500"; + case "unhealthy": + return "text-red-500"; + case "warning": + return "text-yellow-500"; + default: + return "text-gray-400"; + } +} + +const TYPE_ICONS: Record = { + Kubernetes: "logos:kubernetes", + AWS: "logos:aws", + Azure: "logos:microsoft-azure", + GCP: "logos:google-cloud", + File: "codicon:file", + SQL: "codicon:database", + HTTP: "codicon:globe", + Terraform: "logos:terraform-icon", + GitHub: "logos:github-icon", + Trivy: "simple-icons:trivy", + "Orphaned Changes": "codicon:warning" +}; + +export function typeIcon(configType: string): string { + const prefix = configType.split("::")[0]; + return TYPE_ICONS[prefix] || "codicon:symbol-misc"; +} + +export function filterItems( + items: ScrapeResult[], + healthFilter: Set, + typeFilter: Set +): ScrapeResult[] { + return items.filter((item) => { + if (healthFilter.size > 0 && !healthFilter.has(item.health || "unknown")) + return false; + if (typeFilter.size > 0 && !typeFilter.has(item.config_type)) return false; + return true; + }); +} + +export function formatDuration(ms: number): string { + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + return `${mins}m ${remSecs}s`; +} + +export function collectTypes(items: ScrapeResult[]): string[] { + const types = new Set(); + for (const item of items) { + if (item.config_type) types.add(item.config_type); + } + return Array.from(types).sort(); +} + +export interface Lookups { + users: Map; // alias/id -> name + groups: Map; // alias/id -> name + roles: Map; // alias/id -> name + configs: Map; // id -> name (type) +} + +export function buildLookups(results?: FullScrapeResults): Lookups { + const users = new Map(); + const groups = new Map(); + const roles = new Map(); + const configs = new Map(); + + for (const u of results?.external_users || []) { + users.set(u.id, u.name); + if (u.name) users.set(u.name, u.name); + for (const a of u.aliases || []) users.set(a, u.name); + } + for (const g of results?.external_groups || []) { + groups.set(g.id, g.name); + if (g.name) groups.set(g.name, g.name); + for (const a of g.aliases || []) groups.set(a, g.name); + } + for (const r of results?.external_roles || []) { + roles.set(r.id, r.name); + if (r.name) roles.set(r.name, r.name); + for (const a of r.aliases || []) roles.set(a, r.name); + } + for (const c of results?.configs || []) { + const label = c.name ? `${c.name} (${c.config_type})` : c.id; + configs.set(c.id, label); + } + return { users, groups, roles, configs }; +} + +export function resolve(lookup: Map, key: string): string { + return lookup.get(key) || key; +} + +export function resolveConfigId(lookups: Lookups, extId: any): string { + if (!extId) return ""; + if (typeof extId === "string") return lookups.configs.get(extId) || extId; + const eid = extId.external_id || ""; + const cid = extId.config_id || ""; + return lookups.configs.get(eid) || lookups.configs.get(cid) || eid || cid; +} + +// Shared matcher used by both DetailPanel and tree count aggregation. +// Some scrapers populate nested external_config_id (string/object), while others +// use top-level config_id. We also accept aliases for resilient matching. +export function matchesConfig( + a: Pick< + ExternalConfigAccess | ExternalConfigAccessLog, + "external_config_id" | "config_id" + >, + item: Pick +): boolean { + const itemKeys = new Set([item.id, ...(item.aliases || [])]); + + const ext = a.external_config_id; + if (ext) { + if (typeof ext === "string") { + if (itemKeys.has(ext)) return true; + } else if (typeof ext === "object") { + const externalId = (ext as any).external_id; + const configId = (ext as any).config_id; + if (externalId && itemKeys.has(externalId)) return true; + if (configId && itemKeys.has(configId)) return true; + } + } + + if (a.config_id && itemKeys.has(a.config_id)) return true; + return false; +} + +export function statusColor(status: number): string { + if (status >= 200 && status < 300) return "text-green-600"; + if (status >= 300 && status < 400) return "text-blue-600"; + if (status >= 400 && status < 500) return "text-yellow-600"; + if (status >= 500) return "text-red-600"; + return "text-gray-600"; +} + +function containsCI(text: string | undefined | null, q: string): boolean { + return !!text && text.toLowerCase().includes(q); +} + +export type SearchCounts = Record; + +export function globalSearch( + q: string, + results?: FullScrapeResults, + har?: import("./types").HAREntry[], + logs?: string +): SearchCounts { + const counts: SearchCounts = {}; + if (!q) return counts; + const lq = q.toLowerCase(); + + let n = 0; + for (const c of results?.configs || []) { + if ( + containsCI(c.name, lq) || + containsCI(c.config_type, lq) || + containsCI(JSON.stringify(c.config), lq) || + c.aliases?.some((a) => containsCI(a, lq)) || + Object.entries(c.labels || {}).some( + ([k, v]) => containsCI(k, lq) || containsCI(v, lq) + ) || + Object.entries(c.tags || {}).some( + ([k, v]) => containsCI(k, lq) || containsCI(v, lq) + ) + ) + n++; + } + if (n) counts.configs = n; + + n = 0; + for (const e of har || []) { + if ( + containsCI(e.request.url, lq) || + containsCI(e.request.method, lq) || + containsCI(e.request.postData?.text, lq) || + containsCI(e.response.content?.text, lq) + ) + n++; + } + if (n) counts.har = n; + + n = 0; + for (const u of results?.external_users || []) + if (containsCI(u.name, lq) || u.aliases?.some((a) => containsCI(a, lq))) + n++; + if (n) counts.users = n; + + n = 0; + for (const g of results?.external_groups || []) + if (containsCI(g.name, lq) || g.aliases?.some((a) => containsCI(a, lq))) + n++; + if (n) counts.groups = n; + + n = 0; + for (const r of results?.external_roles || []) + if (containsCI(r.name, lq) || r.aliases?.some((a) => containsCI(a, lq))) + n++; + if (n) counts.roles = n; + + n = 0; + for (const a of results?.config_access || []) + if ( + a.external_user_aliases?.some((x) => containsCI(x, lq)) || + a.external_role_aliases?.some((x) => containsCI(x, lq)) || + a.external_group_aliases?.some((x) => containsCI(x, lq)) + ) + n++; + if (n) counts.access = n; + + n = 0; + for (const a of results?.config_access_logs || []) + if (a.external_user_aliases?.some((x) => containsCI(x, lq))) n++; + if (n) counts.access_logs = n; + + if (containsCI(logs, lq)) counts.logs = 1; + + n = 0; + for (const ch of results?.changes || []) + if ( + containsCI(ch.summary, lq) || + containsCI(ch.change_type, lq) || + containsCI(ch.diff, lq) || + containsCI(ch.external_created_by, lq) + ) + n++; + if (n) counts.changes = n; + + return counts; +} + +export function matchesSearch( + q: string, + ...fields: (string | undefined | null)[] +): boolean { + if (!q) return true; + const lq = q.toLowerCase(); + return fields.some((f) => containsCI(f, lq)); +} + +export function matchesSearchArr( + q: string, + arr: (string | undefined)[] +): boolean { + if (!q) return true; + const lq = q.toLowerCase(); + return arr.some((f) => containsCI(f, lq)); +} diff --git a/src/store/preference.state.ts b/src/store/preference.state.ts index 030b7b710e..7241153433 100644 --- a/src/store/preference.state.ts +++ b/src/store/preference.state.ts @@ -1,4 +1,3 @@ -import { useAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { useSearchParams } from "react-router-dom"; diff --git a/src/types/pako.d.ts b/src/types/pako.d.ts new file mode 100644 index 0000000000..32a4c0d406 --- /dev/null +++ b/src/types/pako.d.ts @@ -0,0 +1 @@ +declare module "pako"; diff --git a/src/ui/Avatar/index.tsx b/src/ui/Avatar/index.tsx index f2868fb1ac..608603885c 100644 --- a/src/ui/Avatar/index.tsx +++ b/src/ui/Avatar/index.tsx @@ -106,6 +106,7 @@ export function Avatar({ data-tooltip-content={user?.name?.trim() || user?.email || "?"} > {srcList && src ? ( + // eslint-disable-next-line @next/next/no-img-element {alt} = {}> = { mantineTableBodyRowProps?: { style?: Record; }; + mantineTableBodyCellProps?: { + sx?: Record; + }; displayColumnDefOptions?: { "mrt-row-expand"?: Partial>; }; @@ -79,6 +82,7 @@ function MRTDataTableInner = {}>({ onGroupingChange = () => {}, disableHiding = false, mantineTableBodyRowProps, + mantineTableBodyCellProps, displayColumnDefOptions, urlParamPrefix, defaultPageSize, @@ -194,7 +198,11 @@ function MRTDataTableInner = {}>({ sx: { flex: "1 1 auto" } }, mantineTableBodyCellProps: { - sx: { zIndex: "auto" } + ...mantineTableBodyCellProps, + sx: { + zIndex: "auto", + ...(mantineTableBodyCellProps?.sx ?? {}) + } }, // Use cell-level memoization so that individual cells only re-render // when their cell reference changes, not on every table state update. @@ -240,6 +248,7 @@ function MRTDataTableInner = {}>({ mergedDisplayColumnDefOptions, tableBodyRowProps, disablePagination, + mantineTableBodyCellProps, pageIndex, pageSize, sortState, From deb3ae03171d75bbea3e679f051f86394594f52e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 21 Apr 2026 12:24:12 +0000 Subject: [PATCH 59/72] chore(release): 1.4.238 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a615d47ce0..109fc21fcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.237", + "version": "1.4.238", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index f5c46f25a6..640992b71e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.237", + "version": "1.4.238", "private": false, "files": [ "build", From bbed2200850a07b0cfce73e881b88f6f75098941 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 22 Apr 2026 10:09:59 +0545 Subject: [PATCH 60/72] feat: add jobs summary page with drilldown (#2987) * feat: add jobs summary page with drilldown - switch /settings/jobs to job_history_summary backed table - add /settings/jobs/:jobName drilldown page with detailed history - navigate summary row clicks to drilldown - remove default !SUCCESS filter from drilldown - hide job name column in drilldown table * feat: refine jobs summary disable toggle - persist disable switch with jobs..disabled property - make disable switch destructive red - move average duration to second column - remove toggle success toasts - remove warning/running/stale columns and rename action to disable * fix: address PR review comments for jobs drilldown/toggle - avoid double decoding jobName route param - make legacy property cleanup non-fatal - use allSettled for enable cleanup deletes * feat: replace jobs disable toggle with overrides dialog - add Action/Manage flow on jobs summary table - add shadcn dialog for job property overrides - include schedule, retention success/failed, log level, disable - use switch for disable and select for supported log levels - add field descriptions and prefill values from properties * fix(settings): allow feature flags table to use full width * fix: date range query in job history * fix(jobs-history): handle duplicate key on override save * fix(job-history): prevent overrides dialog render loop * fix(jobs-history): handle range and override save edge cases --- src/App.tsx | 13 + src/api/query-hooks/useJobsHistoryQuery.ts | 97 ++++- src/api/services/jobsHistory.ts | 64 +++- .../Filters/JobsHistoryFilters.tsx | 22 +- .../JobsHistory/JobHistoryOverridesDialog.tsx | 333 ++++++++++++++++++ .../JobsHistory/JobHistorySummary.tsx | 152 ++++++++ .../JobsHistoryDrilldownSettingsPage.tsx | 75 ++++ .../JobsHistory/JobsHistorySettingsPage.tsx | 17 +- src/pages/Settings/FeatureFlagsPage.tsx | 2 +- 9 files changed, 741 insertions(+), 34 deletions(-) create mode 100644 src/components/JobsHistory/JobHistoryOverridesDialog.tsx create mode 100644 src/components/JobsHistory/JobHistorySummary.tsx create mode 100644 src/components/JobsHistory/JobsHistoryDrilldownSettingsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 39fc0a7b5f..71cf0233c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -286,6 +286,10 @@ const JobsHistorySettingsPage = dynamic( () => import("./components/JobsHistory/JobsHistorySettingsPage") ); +const JobsHistoryDrilldownSettingsPage = dynamic( + () => import("./components/JobsHistory/JobsHistoryDrilldownSettingsPage") +); + const AgentsPage = dynamic( () => import("@flanksource-ui/components/Agents/AgentPage") ); @@ -783,6 +787,15 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { true )} /> + , + tables.database, + "read", + true + )} + /> , resourceId?: string, - tableName?: keyof typeof resourceTypeMap + tableName?: keyof typeof resourceTypeMap, + nameOverride?: string ) { - const { timeRangeAbsoluteValue } = useTimeRangeParams( - jobHistoryDefaultDateFilter - ); + useTimeRangeParams(jobHistoryDefaultDateFilter); const [searchParams] = useSearchParams(); const pageIndex = parseInt(searchParams.get("pageIndex") ?? "0"); const pageSize = parseInt(searchParams.get("pageSize") ?? "150"); - const name = searchParams.get("name") ?? ""; + const name = nameOverride + ? nameOverride.includes(":") + ? nameOverride + : `${nameOverride}:1` + : (searchParams.get("name") ?? ""); const sortBy = searchParams.get("sortBy") ?? ""; const sortOrder = searchParams.get("sortOrder") ?? "desc"; const status = searchParams.get("status") ?? ""; @@ -52,8 +72,12 @@ export function useJobsHistoryForSettingQuery( const durationMillis = duration ? durationOptions[duration].valueInMillis : undefined; - const startsAt = timeRangeAbsoluteValue?.from ?? undefined; - const endsAt = timeRangeAbsoluteValue?.to ?? undefined; + const rangeType = searchParams.get("rangeType"); + const range = searchParams.get("range"); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + const timeRange = searchParams.get("timeRange"); + const resourceType = useMemo(() => { if (!tableName) { return undefined; @@ -69,15 +93,36 @@ export function useJobsHistoryForSettingQuery( status, sortBy, sortOrder, - startsAt, - endsAt, duration: durationMillis, resourceId - } satisfies GetJobsHistoryParams; + } satisfies Omit; return useQuery( - ["jobs_history", params], - () => getJobsHistory(params), + ["jobs_history", params, rangeType, range, from, to, timeRange], + () => { + let startsAt: string | undefined; + let endsAt: string | undefined; + + if (rangeType === "absolute") { + startsAt = from ? dayjs(from).toISOString() : undefined; + endsAt = to ? dayjs(to).toISOString() : undefined; + } else if (rangeType === "relative") { + startsAt = range ? parseDateMath(range, false) : undefined; + endsAt = undefined; + } else if (rangeType === "mapped") { + const mapped = mappedOptionsTimeRanges.get( + (timeRange ?? "") as MappedOptionsDisplay + )?.(); + startsAt = mapped?.from ? parseDateMath(mapped.from, false) : undefined; + endsAt = mapped?.to ? parseDateMath(mapped.to, false) : undefined; + } + + return getJobsHistory({ + ...params, + startsAt, + endsAt + }); + }, options ); } @@ -124,3 +169,29 @@ export function useScraperJobsHistoryForSettingQuery( options ); } + +export function useJobsHistorySummaryForSettingQuery( + options?: UseQueryOptions +) { + const [searchParams] = useSearchParams(); + + const pageIndex = parseInt(searchParams.get("pageIndex") ?? "0"); + const pageSize = parseInt(searchParams.get("pageSize") ?? "50"); + const name = searchParams.get("name") ?? ""; + const sortBy = searchParams.get("sortBy") ?? ""; + const sortOrder = searchParams.get("sortOrder") ?? "desc"; + + const params = { + pageIndex, + pageSize, + name, + sortBy, + sortOrder + } satisfies GetJobsHistorySummaryParams; + + return useQuery( + ["job_history_summary", params], + () => getJobsHistorySummary(params), + options + ); +} diff --git a/src/api/services/jobsHistory.ts b/src/api/services/jobsHistory.ts index c2d325073f..c0e31ad3f5 100644 --- a/src/api/services/jobsHistory.ts +++ b/src/api/services/jobsHistory.ts @@ -50,10 +50,18 @@ export const getJobsHistory = async ({ const durationParam = duration ? `&duration_millis=gte.${duration}` : ""; - const rangeParam = - startsAt && endsAt - ? `&and=(created_at.gt.${startsAt},created_at.lt.${endsAt})` - : ""; + const rangeParam = (() => { + if (startsAt && endsAt) { + return `&and=(created_at.gt.${startsAt},created_at.lt.${endsAt})`; + } + if (startsAt) { + return `&created_at=gt.${startsAt}`; + } + if (endsAt) { + return `&created_at=lt.${endsAt}`; + } + return ""; + })(); return resolvePostGrestRequestWithPagination( IncidentCommander.get( @@ -67,6 +75,54 @@ export const getJobsHistory = async ({ ); }; +export type JobHistorySummary = { + name: string; + total: number; + running: number; + success: number; + warning: number; + failed: number; + stale: number; + skipped: number; + last_run_at: string; + average_duration: number | string | null; +}; + +export type GetJobsHistorySummaryParams = { + pageIndex: number; + pageSize: number; + name?: string; + sortBy?: string; + sortOrder?: string; +}; + +export const getJobsHistorySummary = async ({ + pageIndex, + pageSize, + name, + sortBy, + sortOrder +}: GetJobsHistorySummaryParams) => { + const pagingParams = `&limit=${pageSize}&offset=${pageIndex * pageSize}`; + + const nameParam = name ? tristateOutputToQueryFilterParam(name, "name") : ""; + + const sortByParam = sortBy ? `&order=${sortBy}` : "&order=last_run_at"; + + const sortOrderParam = sortOrder ? `.${sortOrder}` : ".desc"; + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get( + `/job_history_summary?select=*${pagingParams}${nameParam}${sortByParam}${sortOrderParam}`, + { + headers: { + Prefer: "count=exact" + } + } + ) + ); +}; + export type JobHistoryNames = { name: string; }; diff --git a/src/components/JobsHistory/Filters/JobsHistoryFilters.tsx b/src/components/JobsHistory/Filters/JobsHistoryFilters.tsx index df773ea515..4833dc8072 100644 --- a/src/components/JobsHistory/Filters/JobsHistoryFilters.tsx +++ b/src/components/JobsHistory/Filters/JobsHistoryFilters.tsx @@ -15,27 +15,37 @@ export const jobHistoryDefaultDateFilter: URLSearchParamsInit = { type JobHistoryFiltersProps = { paramsToReset?: string[]; + showJobNameDropdown?: boolean; + defaultStatusFilter?: string | null; }; export default function JobHistoryFilters({ - paramsToReset = ["pageIndex", "pageSize"] + paramsToReset = ["pageIndex", "pageSize"], + showJobNameDropdown = true, + defaultStatusFilter = "" }: JobHistoryFiltersProps) { const { setTimeRangeParams, getTimeRangeFromUrl } = useTimeRangeParams( jobHistoryDefaultDateFilter ); const timeRangeValue = getTimeRangeFromUrl(); + const defaultFieldValues = defaultStatusFilter + ? { status: defaultStatusFilter } + : undefined; return (
- + {showJobNameDropdown && } diff --git a/src/components/JobsHistory/JobHistoryOverridesDialog.tsx b/src/components/JobsHistory/JobHistoryOverridesDialog.tsx new file mode 100644 index 0000000000..95dd889a4e --- /dev/null +++ b/src/components/JobsHistory/JobHistoryOverridesDialog.tsx @@ -0,0 +1,333 @@ +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { Input } from "@flanksource-ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import { Switch } from "@flanksource-ui/components/ui/switch"; +import { useUser } from "@flanksource-ui/context"; +import { + deleteProperty, + fetchProperties, + saveProperty, + updateProperty +} from "@flanksource-ui/api/services/properties"; +import { formatJobName } from "@flanksource-ui/utils/common"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type DisableProperty = { + name: string; + value: string; +}; + +type JobHistoryOverridesDialogProps = { + open: boolean; + jobName?: string; + onOpenChange: (open: boolean) => void; +}; + +type OverrideField = { + key: string; + label: string; + description: string; + type: "string" | "number" | "switch" | "select"; +}; + +const overrideFields: OverrideField[] = [ + { + key: "schedule", + label: "Schedule", + description: "Cron expression that overrides the job's run schedule.", + type: "string" + }, + { + key: "retention.success", + label: "Retention Success", + description: "Number of successful runs to retain in history.", + type: "number" + }, + { + key: "retention.failed", + label: "Retention Failed", + description: "Number of failed runs to retain in history.", + type: "number" + }, + { + key: "db-log-level", + label: "Log Level", + description: "Override SQL logging verbosity for this job.", + type: "select" + }, + { + key: "disabled", + label: "Disable", + description: + "Disable job execution before it starts and before history is recorded.", + type: "switch" + } +]; + +const supportedLogLevels = ["info", "debug", "trace", "warn", "error"] as const; +const EMPTY_PROPERTIES: DisableProperty[] = []; + +const upsertProperty = async ( + name: string, + value: string, + userID?: string +): Promise => { + const payload = { + name, + value, + created_by: userID + }; + + try { + await saveProperty(payload); + return; + } catch (error) { + const code = isAxiosError(error) + ? (error.response?.data as { code?: string } | undefined)?.code + : undefined; + + // If the property already exists, update it instead. + if (code === "23505") { + await updateProperty(payload); + return; + } + + throw error; + } +}; + +const ignoreNotFound = (error: unknown) => { + if (isAxiosError(error) && error.response?.status === 404) { + return; + } + throw error; +}; + +export default function JobHistoryOverridesDialog({ + open, + jobName, + onOpenChange +}: JobHistoryOverridesDialogProps) { + const user = useUser(); + + const { data: properties } = useQuery({ + queryKey: ["job_history_overrides", "properties", jobName], + queryFn: async () => { + const response = await fetchProperties(); + return (response.data ?? []) as DisableProperty[]; + }, + enabled: open && !!jobName, + staleTime: 0 + }); + + const safeProperties = properties ?? EMPTY_PROPERTIES; + + const initialValues = useMemo(() => { + const values: Record = {}; + if (!jobName) { + return values; + } + + const prefix = `jobs.${jobName}.`; + const byName = new Map( + safeProperties.map((property) => [property.name, property.value]) + ); + + for (const field of overrideFields) { + if (field.key === "disabled") { + values[field.key] = + byName.get(`${prefix}disabled`) ?? + byName.get(`${prefix}disable`) ?? + ""; + continue; + } + + values[field.key] = byName.get(`${prefix}${field.key}`) ?? ""; + } + + return values; + }, [jobName, safeProperties]); + + const [values, setValues] = useState>(initialValues); + const wasOpenRef = useRef(open); + + useEffect(() => { + if (open && !wasOpenRef.current) { + setValues(initialValues); + } + wasOpenRef.current = open; + }, [initialValues, open]); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (!jobName) { + return; + } + + const prefix = `jobs.${jobName}.`; + const operations: Promise[] = []; + + for (const field of overrideFields) { + const value = values[field.key] ?? ""; + + if (field.key === "disabled") { + const disabledName = `${prefix}disabled`; + const legacyDisabledName = `${prefix}disable`; + + if (value === "") { + operations.push( + deleteProperty({ name: disabledName }).catch(ignoreNotFound), + deleteProperty({ name: legacyDisabledName }).catch(ignoreNotFound) + ); + } else { + operations.push(upsertProperty(disabledName, value, user.user?.id)); + operations.push( + deleteProperty({ name: legacyDisabledName }).catch(ignoreNotFound) + ); + } + + continue; + } + + const propertyName = `${prefix}${field.key}`; + if (value === "") { + operations.push( + deleteProperty({ name: propertyName }).catch(ignoreNotFound) + ); + } else { + operations.push(upsertProperty(propertyName, value, user.user?.id)); + } + } + + const results = await Promise.allSettled(operations); + const failures = results.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + throw new Error("Failed to save one or more overrides"); + } + }, + onSuccess: () => { + toastSuccess("Job overrides updated"); + onOpenChange(false); + }, + onError: (error) => { + toastError((error as Error).message); + } + }); + + return ( + + + + + Edit Job Overrides {jobName ? `- ${formatJobName(jobName)}` : ""} + + + Configure property overrides for this job. Leave a field empty to + use the default behavior. + + + +
+ {overrideFields.map((field) => ( +
+
+ +

+ {field.description} +

+
+ +
+ {field.type === "switch" ? ( + { + setValues((current) => ({ + ...current, + [field.key]: checked ? "true" : "" + })); + }} + /> + ) : field.type === "select" ? ( + + ) : ( + { + const nextValue = event.target.value; + setValues((current) => ({ + ...current, + [field.key]: nextValue + })); + }} + placeholder="Default" + /> + )} +
+
+ ))} +
+ + + + + +
+
+ ); +} diff --git a/src/components/JobsHistory/JobHistorySummary.tsx b/src/components/JobsHistory/JobHistorySummary.tsx new file mode 100644 index 0000000000..27162010cd --- /dev/null +++ b/src/components/JobsHistory/JobHistorySummary.tsx @@ -0,0 +1,152 @@ +import { Button } from "@flanksource-ui/components/ui/button"; +import { JobHistorySummary as JobHistorySummaryType } from "@flanksource-ui/api/services/jobsHistory"; +import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; +import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; +import { formatJobName } from "@flanksource-ui/utils/common"; +import { formatDuration } from "@flanksource-ui/utils/date"; +import { MRT_ColumnDef } from "mantine-react-table"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import JobHistoryOverridesDialog from "./JobHistoryOverridesDialog"; + +type JobHistorySummaryProps = { + data: JobHistorySummaryType[]; + isLoading?: boolean; + isRefetching?: boolean; + pageCount: number; + totalEntries?: number; +}; + +export default function JobHistorySummary({ + data, + isLoading, + isRefetching, + pageCount, + totalEntries +}: JobHistorySummaryProps) { + const navigate = useNavigate(); + + const [isOverridesDialogOpen, setIsOverridesDialogOpen] = useState(false); + const [selectedJobName, setSelectedJobName] = useState(); + + const columns: MRT_ColumnDef[] = useMemo( + () => [ + { + header: "Job Name", + id: "name", + accessorKey: "name", + minSize: 220, + Cell: ({ row }) => {formatJobName(row.original.name)} + }, + { + header: "Average Duration", + id: "average_duration", + accessorKey: "average_duration", + size: 100, + Cell: ({ row }) => { + const rawValue = row.original.average_duration; + const duration = Number(rawValue ?? 0); + + if (!Number.isFinite(duration) || duration <= 0) { + return -; + } + + return {formatDuration(duration)}; + } + }, + { + header: "Total", + id: "total", + accessorKey: "total", + size: 70 + }, + { + header: "Success", + id: "success", + accessorKey: "success", + size: 70 + }, + { + header: "Failed", + id: "failed", + accessorKey: "failed", + size: 70, + Cell: ({ row }) => ( + 0 ? "text-red-500" : ""}> + {row.original.failed} + + ) + }, + { + header: "Skipped", + id: "skipped", + accessorKey: "skipped", + size: 70 + }, + { + header: "Last Run", + id: "last_run_at", + accessorKey: "last_run_at", + size: 90, + Cell: MRTDateCell + }, + { + header: "Action", + id: "edit", + enableSorting: false, + size: 80, + Cell: ({ row }) => ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+ ) + } + ], + [] + ); + + return ( + <> + { + navigate(`/settings/jobs/${encodeURIComponent(row.name)}`); + }} + /> + + { + setIsOverridesDialogOpen(isOpen); + if (!isOpen) { + setSelectedJobName(undefined); + } + }} + /> + + ); +} diff --git a/src/components/JobsHistory/JobsHistoryDrilldownSettingsPage.tsx b/src/components/JobsHistory/JobsHistoryDrilldownSettingsPage.tsx new file mode 100644 index 0000000000..5657858165 --- /dev/null +++ b/src/components/JobsHistory/JobsHistoryDrilldownSettingsPage.tsx @@ -0,0 +1,75 @@ +import { formatJobName } from "@flanksource-ui/utils/common"; +import { useParams, useSearchParams } from "react-router-dom"; +import { useJobsHistoryForSettingQuery } from "../../api/query-hooks/useJobsHistoryQuery"; +import { BreadcrumbNav, BreadcrumbRoot } from "../../ui/BreadcrumbNav"; +import { Head } from "../../ui/Head"; +import { SearchLayout } from "../../ui/Layout/SearchLayout"; +import JobHistoryFilters from "./Filters/JobsHistoryFilters"; +import JobsHistoryTable from "./JobsHistoryTable"; + +export default function JobsHistoryDrilldownSettingsPage() { + const { jobName } = useParams<{ jobName: string }>(); + const [searchParams] = useSearchParams(); + + const decodedJobName = jobName ?? ""; + + const pageSize = parseInt(searchParams.get("pageSize") ?? "50"); + + const { data, isLoading, refetch, isRefetching } = + useJobsHistoryForSettingQuery( + { + keepPreviousData: true + }, + undefined, + undefined, + decodedJobName + ); + + const jobs = data?.data; + const totalEntries = data?.totalEntries; + const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : -1; + + return ( + <> + + + Job History + , + + {formatJobName(decodedJobName)} + + ]} + /> + } + onRefresh={refetch} + contentClass="p-0 h-full" + loading={isLoading || isRefetching} + > +
+ + + +
+
+ + ); +} diff --git a/src/components/JobsHistory/JobsHistorySettingsPage.tsx b/src/components/JobsHistory/JobsHistorySettingsPage.tsx index e1abd9da8e..61a102044b 100644 --- a/src/components/JobsHistory/JobsHistorySettingsPage.tsx +++ b/src/components/JobsHistory/JobsHistorySettingsPage.tsx @@ -1,10 +1,9 @@ import { useSearchParams } from "react-router-dom"; -import { useJobsHistoryForSettingQuery } from "../../api/query-hooks/useJobsHistoryQuery"; +import { useJobsHistorySummaryForSettingQuery } from "../../api/query-hooks/useJobsHistoryQuery"; import { BreadcrumbNav, BreadcrumbRoot } from "../../ui/BreadcrumbNav"; import { Head } from "../../ui/Head"; import { SearchLayout } from "../../ui/Layout/SearchLayout"; -import JobHistoryFilters from "./Filters/JobsHistoryFilters"; -import JobsHistoryTable from "./JobsHistoryTable"; +import JobHistorySummary from "./JobHistorySummary"; export default function JobsHistorySettingsPage() { const [searchParams] = useSearchParams(); @@ -12,11 +11,11 @@ export default function JobsHistorySettingsPage() { const pageSize = parseInt(searchParams.get("pageSize") ?? "50"); const { data, isLoading, refetch, isRefetching } = - useJobsHistoryForSettingQuery({ + useJobsHistorySummaryForSettingQuery({ keepPreviousData: true }); - const jobs = data?.data; + const summary = data?.data; const totalEntries = data?.totalEntries; const pageCount = totalEntries ? Math.ceil(totalEntries / pageSize) : -1; @@ -38,14 +37,12 @@ export default function JobsHistorySettingsPage() { loading={isLoading || isRefetching} >
- - -
diff --git a/src/pages/Settings/FeatureFlagsPage.tsx b/src/pages/Settings/FeatureFlagsPage.tsx index 5d87d773fd..d3c8808579 100644 --- a/src/pages/Settings/FeatureFlagsPage.tsx +++ b/src/pages/Settings/FeatureFlagsPage.tsx @@ -100,7 +100,7 @@ export function FeatureFlagsPage() { contentClass="p-0 h-full" loading={isLoading} > -
+
Date: Wed, 22 Apr 2026 04:25:52 +0000 Subject: [PATCH 61/72] chore(release): 1.4.239 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 109fc21fcc..0dfc1b8081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.238", + "version": "1.4.239", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 640992b71e..33104c5242 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.238", + "version": "1.4.239", "private": false, "files": [ "build", From 4cd3438b16ada03eed63e097d0afb9b93bcf8582 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 22 Apr 2026 10:13:10 +0545 Subject: [PATCH 62/72] feat: Permission workbench (#2983) * feat: Permissions interactive settings * chore: refactor workbench --- src/api/services/permissions.ts | 23 +- src/api/services/rbac.ts | 17 +- src/components/MCP/UserList.tsx | 7 +- .../Permissions/EffectiveAccessBadge.tsx | 67 +++-- .../Permissions/PermissionSubjectPanel.tsx | 2 +- .../Permissions/ResourceSelectorPanel.tsx | 9 +- .../SubjectPermissions/DirectMatrixCell.tsx | 33 +++ .../EffectiveMatrixCell.tsx | 25 ++ .../SubjectPermissions/MatrixDrawer.tsx | 56 ++++ .../PermissionsMatrixTable.tsx | 109 ++++++++ .../ResourceTypeMatrixSection.tsx | 60 +++++ .../SubjectPermissionsHeader.tsx | 36 +++ .../SubjectPermissionsMatrixContent.tsx | 119 +++++++++ .../Permissions/SubjectPermissions/shared.ts | 250 ++++++++++++++++++ .../useEffectiveSubjectAccess.ts | 101 +++++++ .../usePermissionResources.ts | 101 +++++++ .../useSubjectPermissionAccess.ts | 119 +++++++++ .../SubjectPermissionsWorkbench.tsx | 145 ++++++++++ .../permissions/mcpPermissionCardMappings.ts | 6 +- .../permissions/useMcpResourcePermissions.ts | 19 +- .../Settings/PermissionsSubjectsPage.tsx | 47 ++-- src/pages/Settings/mcp/McpOverviewPage.tsx | 9 +- .../Settings/mcp/McpSubjectAccessPage.tsx | 29 +- src/ui/Tabs/FlatTabs.tsx | 2 +- 24 files changed, 1299 insertions(+), 92 deletions(-) create mode 100644 src/components/Permissions/SubjectPermissions/DirectMatrixCell.tsx create mode 100644 src/components/Permissions/SubjectPermissions/EffectiveMatrixCell.tsx create mode 100644 src/components/Permissions/SubjectPermissions/MatrixDrawer.tsx create mode 100644 src/components/Permissions/SubjectPermissions/PermissionsMatrixTable.tsx create mode 100644 src/components/Permissions/SubjectPermissions/ResourceTypeMatrixSection.tsx create mode 100644 src/components/Permissions/SubjectPermissions/SubjectPermissionsHeader.tsx create mode 100644 src/components/Permissions/SubjectPermissions/SubjectPermissionsMatrixContent.tsx create mode 100644 src/components/Permissions/SubjectPermissions/shared.ts create mode 100644 src/components/Permissions/SubjectPermissions/useEffectiveSubjectAccess.ts create mode 100644 src/components/Permissions/SubjectPermissions/usePermissionResources.ts create mode 100644 src/components/Permissions/SubjectPermissions/useSubjectPermissionAccess.ts create mode 100644 src/components/Permissions/SubjectPermissionsWorkbench.tsx diff --git a/src/api/services/permissions.ts b/src/api/services/permissions.ts index 43a1839787..9e4760b325 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -157,12 +157,27 @@ export function recheckPermission(id: string) { }); } -// Source marker used by the MCP Settings UI for permissions it creates/manages. -export const MCP_SETTINGS_PERMISSION_SOURCE = "mcp_settings" as const; +// Source marker used by settings UIs for permissions they create/manage. +export const INTERACTIVE_SETTINGS_PERMISSION_SOURCE = + "interactive_settings" as const; + +export function isSettingsManagedPermissionSource(source?: string | null) { + return source === INTERACTIVE_SETTINGS_PERMISSION_SOURCE; +} + +export async function fetchSettingsManagedSubjectPermissions( + subjectId: string +) { + const response = await IncidentCommander.get( + `/permissions_summary?select=*&subject=eq.${subjectId}&source=eq.${INTERACTIVE_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` + ); + + return response.data ?? []; +} export async function fetchMcpRunPermissions() { const response = await IncidentCommander.get( - `/permissions_summary?select=*&action=eq.mcp:run&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` + `/permissions_summary?select=*&action=eq.mcp:run&source=eq.${INTERACTIVE_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` ); return response.data ?? []; @@ -170,7 +185,7 @@ export async function fetchMcpRunPermissions() { export async function fetchMcpUserPermissions() { const response = await IncidentCommander.get( - `/permissions_summary?select=*&action=eq.mcp:use&object=eq.mcp&source=eq.${MCP_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` + `/permissions_summary?select=*&action=eq.mcp:use&object=eq.mcp&source=eq.${INTERACTIVE_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` ); return response.data ?? []; diff --git a/src/api/services/rbac.ts b/src/api/services/rbac.ts index 17948e022b..dc7723d968 100644 --- a/src/api/services/rbac.ts +++ b/src/api/services/rbac.ts @@ -58,6 +58,7 @@ export async function getPermissions(id: string): Promise { export type SubjectAccessReviewResource = { playbook?: string; view?: string; + connection?: string; config?: string; check?: string; global?: string; @@ -66,6 +67,8 @@ export type SubjectAccessReviewResource = { export type SubjectAccessReviewAction = | "read" + | "update" + | "delete" | "mcp:run" | "mcp:use" | "playbook:run" @@ -118,7 +121,7 @@ export async function reviewSubjectAccess( export type EffectiveSubjectResourceAccessResource = { id: string; - type: "playbook" | "view"; + type: "playbook" | "view" | "connection"; }; export type EffectiveSubjectResourceAccessRequest = { @@ -129,7 +132,7 @@ export type EffectiveSubjectResourceAccessRequest = { export type EffectiveSubjectResourceAccessResult = { resourceId: string; - resourceType: "playbook" | "view"; + resourceType: "playbook" | "view" | "connection"; allowed: boolean; }; @@ -142,7 +145,7 @@ export type EffectiveSubjectResourceAccessResponse = { type SubjectAccessSearchRequest = { subject: string; action: SubjectAccessReviewAction; - resource_types?: Array<"playbook" | "view">; + resource_types?: Array<"playbook" | "view" | "connection">; search?: string; namespace?: string; }; @@ -150,12 +153,12 @@ type SubjectAccessSearchRequest = { type SubjectAccessSearchResponse = { subject: string; action: SubjectAccessReviewAction; - resource_types: Array<"playbook" | "view">; + resource_types: Array<"playbook" | "view" | "connection">; total: number; limit: number; offset: number; results: Array<{ - resource_type: "playbook" | "view"; + resource_type: "playbook" | "view" | "connection"; id: string; name: string; namespace?: string; @@ -220,7 +223,9 @@ export async function fetchEffectiveResourceSubjectAccess( const resource: SubjectAccessReviewResource = payload.resource.type === "playbook" ? { playbook: payload.resource.id } - : { view: payload.resource.id }; + : payload.resource.type === "view" + ? { view: payload.resource.id } + : { connection: payload.resource.id }; const response = await reviewSubjectAccess({ resource, diff --git a/src/components/MCP/UserList.tsx b/src/components/MCP/UserList.tsx index 50ff0dfa0f..9de9fa186d 100644 --- a/src/components/MCP/UserList.tsx +++ b/src/components/MCP/UserList.tsx @@ -1,5 +1,5 @@ import { - MCP_SETTINGS_PERMISSION_SOURCE, + isSettingsManagedPermissionSource, PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; @@ -49,9 +49,8 @@ export default function UserList({ {group.subjects.map((subject) => { const permissions = permissionsByUser.get(subject.id) ?? []; - const activePermission = permissions.find( - (permission) => - permission.source === MCP_SETTINGS_PERMISSION_SOURCE + const activePermission = permissions.find((permission) => + isSettingsManagedPermissionSource(permission.source) ); const access = !activePermission diff --git a/src/components/Permissions/EffectiveAccessBadge.tsx b/src/components/Permissions/EffectiveAccessBadge.tsx index 763127bcf1..24b0c9f3b6 100644 --- a/src/components/Permissions/EffectiveAccessBadge.tsx +++ b/src/components/Permissions/EffectiveAccessBadge.tsx @@ -5,33 +5,48 @@ import { } from "@flanksource-ui/components/ui/tooltip"; import { Check, X } from "lucide-react"; -type EffectiveAccessBadgeProps = { - isAllowed: boolean; -}; +type EffectiveAccessBadgeProps = + | { + state: "allowed" | "denied" | "unknown"; + unknownReason?: string; + } + | { + isAllowed: boolean; + }; + +export default function EffectiveAccessBadge(props: EffectiveAccessBadgeProps) { + const state = + "state" in props ? props.state : props.isAllowed ? "allowed" : "denied"; + + if (state === "unknown") { + return ( + + + + ? + + + + {("unknownReason" in props && props.unknownReason) || + "Effective access evaluation failed for this cell."} + + + ); + } -export default function EffectiveAccessBadge({ - isAllowed -}: EffectiveAccessBadgeProps) { return ( - - - - {isAllowed ? ( - - ) : ( - - )} - - - - Effective access: {isAllowed ? "Allowed" : "Denied"} - - + + {state === "allowed" ? ( + + ) : ( + + )} + ); } diff --git a/src/components/Permissions/PermissionSubjectPanel.tsx b/src/components/Permissions/PermissionSubjectPanel.tsx index fdf21580a6..31b57ceb51 100644 --- a/src/components/Permissions/PermissionSubjectPanel.tsx +++ b/src/components/Permissions/PermissionSubjectPanel.tsx @@ -31,7 +31,7 @@ export default function PermissionSubjectPanel({ onSelectSubject }: PermissionSubjectPanelProps) { return ( -
+
permission.action === "mcp:run" && - permission.source === "mcp_settings" && + isSettingsManagedPermissionSource(permission.source) && permission.subject === subject.id && permission.subject_type === subjectType && permissionMatchesResource(permission, resource) @@ -168,7 +171,7 @@ export default function ResourceSelectorPanel({ const wildcardPermissions = permissions.filter((permission) => { if ( permission.action !== "mcp:run" || - permission.source !== "mcp_settings" || + !isSettingsManagedPermissionSource(permission.source) || permission.subject !== selectedSubject.id || permission.subject_type !== subjectType ) { diff --git a/src/components/Permissions/SubjectPermissions/DirectMatrixCell.tsx b/src/components/Permissions/SubjectPermissions/DirectMatrixCell.tsx new file mode 100644 index 0000000000..c0acf81668 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/DirectMatrixCell.tsx @@ -0,0 +1,33 @@ +import TriStateAccessSwitch from "@flanksource-ui/components/Permissions/TriStateAccessSwitch"; +import { AccessValue } from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; + +type DirectMatrixCellProps = { + value: AccessValue; + disabled?: boolean; + isReadOnly?: boolean; + isWildcard?: boolean; + onChange: (value: AccessValue) => void; +}; + +export default function DirectMatrixCell({ + value, + disabled, + isReadOnly, + isWildcard, + onChange +}: DirectMatrixCellProps) { + return ( +
+ + {isReadOnly ? ( + Managed externally + ) : isWildcard ? ( + Type-wide rule + ) : null} +
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/EffectiveMatrixCell.tsx b/src/components/Permissions/SubjectPermissions/EffectiveMatrixCell.tsx new file mode 100644 index 0000000000..e455d8c2eb --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/EffectiveMatrixCell.tsx @@ -0,0 +1,25 @@ +import EffectiveAccessBadge from "@flanksource-ui/components/Permissions/EffectiveAccessBadge"; +import { EffectiveState } from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; + +type EffectiveMatrixCellProps = { + state: EffectiveState; + notChecked?: boolean; +}; + +export default function EffectiveMatrixCell({ + state, + notChecked = false +}: EffectiveMatrixCellProps) { + if (notChecked) { + return Not checked; + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/MatrixDrawer.tsx b/src/components/Permissions/SubjectPermissions/MatrixDrawer.tsx new file mode 100644 index 0000000000..e712f6b8f5 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/MatrixDrawer.tsx @@ -0,0 +1,56 @@ +import DirectMatrixCell from "@flanksource-ui/components/Permissions/SubjectPermissions/DirectMatrixCell"; +import { AccessValue } from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import { motion } from "motion/react"; + +export type MatrixDrawerRow = { + key: string; + action: string; + access: AccessValue; + isReadOnly: boolean; + isWildcard: boolean; + disabled?: boolean; + onChange: (next: AccessValue) => void; +}; + +type MatrixDrawerProps = { + rows: MatrixDrawerRow[]; +}; + +const DRAWER_OPEN_TRANSITION = { + duration: 0.18, + ease: "easeOut" +} as const; + +export default function MatrixDrawer({ rows }: MatrixDrawerProps) { + return ( + +
+ {rows.map((row) => ( +
+
+ {row.action} +
+ +
+ +
+
+ ))} +
+
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/PermissionsMatrixTable.tsx b/src/components/Permissions/SubjectPermissions/PermissionsMatrixTable.tsx new file mode 100644 index 0000000000..b838afab82 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/PermissionsMatrixTable.tsx @@ -0,0 +1,109 @@ +import { Icon } from "@flanksource-ui/ui/Icons/Icon"; +import { Fragment, ReactNode } from "react"; + +export type PermissionsMatrixRow = { + id: string; + displayName: string; + subtitle?: string; + icon?: string; +}; + +type PermissionsMatrixTableProps = { + rows: TRow[]; + actions: string[]; + renderCell: (row: TRow, action: string) => ReactNode; + onRowClick?: (row: TRow) => void; + isRowSelected?: (row: TRow) => boolean; + isRowExpanded?: (row: TRow) => boolean; + renderExpandedRow?: (row: TRow) => ReactNode; +}; + +export default function PermissionsMatrixTable< + TRow extends PermissionsMatrixRow +>({ + rows, + actions, + renderCell, + onRowClick, + isRowSelected, + isRowExpanded, + renderExpandedRow +}: PermissionsMatrixTableProps) { + return ( +
+ + + + + {actions.map((action) => ( + + ))} + + + + {rows.map((row) => { + const selected = isRowSelected?.(row) ?? false; + const expanded = isRowExpanded?.(row) ?? false; + + return ( + + onRowClick(row) : undefined} + className={onRowClick ? "cursor-pointer" : undefined} + > + + {actions.map((action) => ( + + ))} + + {expanded && renderExpandedRow ? ( + + + + ) : null} + + ); + })} + +
+ Resource + + {action} +
+
+ +
+
+ {row.displayName} +
+
+ {row.subtitle || "—"} +
+
+
+
+ {renderCell(row, action)} +
+ {renderExpandedRow(row)} +
+
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/ResourceTypeMatrixSection.tsx b/src/components/Permissions/SubjectPermissions/ResourceTypeMatrixSection.tsx new file mode 100644 index 0000000000..24de4de3b2 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/ResourceTypeMatrixSection.tsx @@ -0,0 +1,60 @@ +import PermissionsMatrixTable, { + PermissionsMatrixRow +} from "@flanksource-ui/components/Permissions/SubjectPermissions/PermissionsMatrixTable"; +import { ReactNode } from "react"; + +type ResourceTypeMatrixSectionProps = { + title: string; + count: number; + actions: string[]; + rows: TRow[]; + isRefreshing?: boolean; + onRowClick?: (row: TRow) => void; + isRowSelected?: (row: TRow) => boolean; + isRowExpanded?: (row: TRow) => boolean; + renderExpandedRow?: (row: TRow) => ReactNode; + renderCell: (row: TRow, action: string) => ReactNode; +}; + +export default function ResourceTypeMatrixSection< + TRow extends PermissionsMatrixRow +>({ + title, + count, + actions, + rows, + isRefreshing = false, + onRowClick, + isRowSelected, + isRowExpanded, + renderExpandedRow, + renderCell +}: ResourceTypeMatrixSectionProps) { + return ( +
+
+ + {title} ({count}) + + {isRefreshing ? ( + + ) : null} +
+
+ +
+
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/SubjectPermissionsHeader.tsx b/src/components/Permissions/SubjectPermissions/SubjectPermissionsHeader.tsx new file mode 100644 index 0000000000..792f29ded7 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/SubjectPermissionsHeader.tsx @@ -0,0 +1,36 @@ +import { PermissionSubject } from "@flanksource-ui/api/services/permissions"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Input } from "@flanksource-ui/components/ui/input"; + +type SubjectPermissionsHeaderProps = { + selectedSubject: PermissionSubject; + search: string; + onSearchChange: (value: string) => void; +}; + +export default function SubjectPermissionsHeader({ + selectedSubject, + search, + onSearchChange +}: SubjectPermissionsHeaderProps) { + return ( +
+
+ +
+
+ {selectedSubject.name} +
+
+
+ +
+ onSearchChange(event.target.value)} + /> +
+
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/SubjectPermissionsMatrixContent.tsx b/src/components/Permissions/SubjectPermissions/SubjectPermissionsMatrixContent.tsx new file mode 100644 index 0000000000..3a9e68f353 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/SubjectPermissionsMatrixContent.tsx @@ -0,0 +1,119 @@ +import EffectiveMatrixCell from "@flanksource-ui/components/Permissions/SubjectPermissions/EffectiveMatrixCell"; +import MatrixDrawer from "@flanksource-ui/components/Permissions/SubjectPermissions/MatrixDrawer"; +import ResourceTypeMatrixSection from "@flanksource-ui/components/Permissions/SubjectPermissions/ResourceTypeMatrixSection"; +import { + AccessValue, + DEFAULT_DIRECT_STATE, + DirectAccessState, + EffectiveAccessMap, + PermissionResource, + ResourceTypeGroup, + getResourceActionKey, + isSamePermissionResource +} from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import { RefObject } from "react"; + +type SubjectPermissionsMatrixContentProps = { + loading: boolean; + groupedResources: ResourceTypeGroup[]; + selectedResource: PermissionResource | null; + directAccessByResourceAction: Record; + effectiveAccessByAction: EffectiveAccessMap; + isCheckingEffectiveAccess: boolean; + isSubmitting: boolean; + scrollRef: RefObject; + onToggleResource: (resource: PermissionResource) => void; + onPermissionAccessChange: ( + resource: PermissionResource, + action: string, + access: AccessValue + ) => void; +}; + +export default function SubjectPermissionsMatrixContent({ + loading, + groupedResources, + selectedResource, + directAccessByResourceAction, + effectiveAccessByAction, + isCheckingEffectiveAccess, + isSubmitting, + scrollRef, + onToggleResource, + onPermissionAccessChange +}: SubjectPermissionsMatrixContentProps) { + const isSelectedResource = (resource: PermissionResource) => { + return isSamePermissionResource(selectedResource, resource); + }; + + return ( +
+
+ {loading ? ( +
+
+ Loading subject access... +
+ ) : groupedResources.length === 0 ? ( +
+ No resources match the current filters. +
+ ) : ( +
+ {groupedResources.map((group) => ( + isSelectedResource(resource)} + isRowExpanded={(resource) => isSelectedResource(resource)} + renderExpandedRow={(resource) => ( + { + const key = getResourceActionKey(resource, action); + const direct = + directAccessByResourceAction[key] ?? + DEFAULT_DIRECT_STATE; + + return { + key: `${resource.id}:${action}`, + action, + access: direct.access, + isReadOnly: direct.isReadOnly, + isWildcard: direct.isWildcard, + disabled: isSubmitting, + onChange: (next) => { + onPermissionAccessChange(resource, action, next); + } + }; + })} + /> + )} + renderCell={(resource, action) => { + if (!resource.actions.includes(action)) { + return ; + } + + return ( + + ); + }} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/Permissions/SubjectPermissions/shared.ts b/src/components/Permissions/SubjectPermissions/shared.ts new file mode 100644 index 0000000000..be5172df4f --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/shared.ts @@ -0,0 +1,250 @@ +import { + isSettingsManagedPermissionSource, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; + +export type AccessValue = "allow" | "deny" | "default"; +export type ResourceKind = "playbook" | "view" | "connection"; +export type EffectiveState = "allowed" | "denied" | "unknown"; +export type PermissionSelectorKey = "playbooks" | "views" | "connections"; + +type SelectorRef = { + name?: string; + namespace?: string; +}; + +export type PermissionResource = { + id: string; + kind: ResourceKind; + name: string; + displayName: string; + namespace?: string; + icon?: string; + subtitle?: string; + selectorKey: PermissionSelectorKey; + actions: string[]; +}; + +export type ResourceTypeGroup = { + kind: ResourceKind; + label: string; + actions: string[]; + resources: PermissionResource[]; +}; + +export type EffectiveAccessMap = Record; + +export type DirectAccessState = { + access: AccessValue; + isReadOnly: boolean; + source?: string; + isWildcard: boolean; +}; + +export const RESOURCE_KIND_ORDER: ResourceKind[] = [ + "playbook", + "view", + "connection" +]; + +export const RESOURCE_KIND_CONFIG: Record< + ResourceKind, + { + label: string; + actions: string[]; + selectorKey: PermissionSelectorKey; + } +> = { + playbook: { + label: "Playbooks", + actions: [ + "read", + "playbook:run", + "playbook:cancel", + "playbook:approve", + "mcp:run" + ], + selectorKey: "playbooks" + }, + view: { + label: "Views", + actions: ["read", "mcp:run"], + selectorKey: "views" + }, + connection: { + label: "Connections", + actions: ["read"], + selectorKey: "connections" + } +}; + +export const DEFAULT_DIRECT_STATE: DirectAccessState = { + access: "default", + isReadOnly: false, + isWildcard: false +}; + +export function getResourceActionKey( + resource: Pick, + action: string +) { + return `${resource.kind}:${resource.id}:${action}`; +} + +export function getRefsForPermission( + permission: PermissionsSummary, + selectorKey: PermissionSelectorKey +) { + return permission.object_selector?.[selectorKey] ?? []; +} + +export function selectorRefMatchesResource( + ref: SelectorRef, + resource: Pick +) { + if (!ref?.name) { + return false; + } + + if (ref.name === "*" && !ref.namespace) { + return true; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; +} + +export function selectorRefMatchesExactResource( + ref: SelectorRef, + resource: Pick +) { + if (!ref?.name || ref.name === "*") { + return false; + } + + if (ref.namespace) { + return ref.namespace === resource.namespace && ref.name === resource.name; + } + + return ref.name === resource.name; +} + +function selectorRefIsWildcard(ref: SelectorRef) { + return ref?.name === "*" && !ref?.namespace; +} + +function getPermissionSourcePriority(permission: PermissionsSummary) { + if (isSettingsManagedPermissionSource(permission.source)) { + return 0; + } + + return 1; +} + +export function sortCanonicalPermissions(permissions: PermissionsSummary[]) { + return [...permissions].sort((a, b) => { + const sourceDiff = + getPermissionSourcePriority(a) - getPermissionSourcePriority(b); + + if (sourceDiff !== 0) { + return sourceDiff; + } + + return (a.id ?? "").localeCompare(b.id ?? ""); + }); +} + +export function sortPermissionResources(resources: PermissionResource[]) { + return [...resources].sort((a, b) => { + const typeDiff = + RESOURCE_KIND_ORDER.indexOf(a.kind) - RESOURCE_KIND_ORDER.indexOf(b.kind); + + if (typeDiff !== 0) { + return typeDiff; + } + + return a.displayName.localeCompare(b.displayName, undefined, { + sensitivity: "base" + }); + }); +} + +export function getDirectAccessState( + permissions: PermissionsSummary[], + subject: PermissionSubject, + resource: PermissionResource, + action: string +): DirectAccessState { + const subjectType = mapSubjectType(subject.type); + + const matchingPermissions = permissions.filter((permission) => { + if ( + permission.action !== action || + permission.subject !== subject.id || + permission.subject_type !== subjectType + ) { + return false; + } + + return getRefsForPermission(permission, resource.selectorKey).some((ref) => + selectorRefMatchesResource(ref, resource) + ); + }); + + if (matchingPermissions.length === 0) { + return DEFAULT_DIRECT_STATE; + } + + const isReadOnly = matchingPermissions.some( + (permission) => permission.source === "KubernetesCRD" + ); + const isWildcard = matchingPermissions.some((permission) => + getRefsForPermission(permission, resource.selectorKey).some( + selectorRefIsWildcard + ) + ); + + return { + access: matchingPermissions.some((permission) => permission.deny === true) + ? "deny" + : "allow", + isReadOnly, + source: matchingPermissions[0]?.source, + isWildcard + }; +} + +export function groupResourcesByType( + resources: PermissionResource[] +): ResourceTypeGroup[] { + const grouped = new Map(); + + for (const resource of resources) { + const list = grouped.get(resource.kind) ?? []; + list.push(resource); + grouped.set(resource.kind, list); + } + + return RESOURCE_KIND_ORDER.map((kind) => ({ + kind, + label: RESOURCE_KIND_CONFIG[kind].label, + actions: RESOURCE_KIND_CONFIG[kind].actions, + resources: grouped.get(kind) ?? [] + })).filter((group) => group.resources.length > 0); +} + +export function isSamePermissionResource( + left: Pick | null | undefined, + right: Pick | null | undefined +) { + if (!left || !right) { + return false; + } + + return left.id === right.id && left.kind === right.kind; +} diff --git a/src/components/Permissions/SubjectPermissions/useEffectiveSubjectAccess.ts b/src/components/Permissions/SubjectPermissions/useEffectiveSubjectAccess.ts new file mode 100644 index 0000000000..086bf9067e --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/useEffectiveSubjectAccess.ts @@ -0,0 +1,101 @@ +import { fetchEffectiveSubjectResourceAccess } from "@flanksource-ui/api/services/rbac"; +import { + EffectiveAccessMap, + PermissionResource, + ResourceKind +} from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import toast from "react-hot-toast"; + +type UseEffectiveSubjectAccessProps = { + selectedSubjectId: string; + resources: PermissionResource[]; +}; + +export default function useEffectiveSubjectAccess({ + selectedSubjectId, + resources +}: UseEffectiveSubjectAccessProps) { + const scopeKey = useMemo( + () => + resources + .map( + (resource) => + `${resource.kind}:${resource.id}:${resource.actions.join(",")}` + ) + .join("|"), + [resources] + ); + + const { data: effectiveAccessByAction = {}, isFetching } = + useQuery({ + queryKey: [ + "permissions-subjects", + "effective-access", + selectedSubjectId, + scopeKey + ], + enabled: resources.length > 0, + queryFn: async () => { + const byAction = new Map< + string, + Array<{ id: string; type: ResourceKind }> + >(); + + for (const resource of resources) { + for (const action of resource.actions) { + const list = byAction.get(action) ?? []; + list.push({ + id: resource.id, + type: resource.kind + }); + byAction.set(action, list); + } + } + + const next: EffectiveAccessMap = {}; + let hasEffectiveAccessFetchFailure = false; + + await Promise.all( + Array.from(byAction.entries()).map( + async ([action, actionResources]) => { + try { + const response = await fetchEffectiveSubjectResourceAccess({ + subject: selectedSubjectId, + action: action as any, + resources: actionResources + }); + + for (const result of response.results ?? []) { + next[ + `${result.resourceType}:${result.resourceId}:${action}` + ] = result.allowed ? "allowed" : "denied"; + } + } catch { + hasEffectiveAccessFetchFailure = true; + for (const resource of actionResources) { + next[`${resource.type}:${resource.id}:${action}`] = "unknown"; + } + } + } + ) + ); + + if (hasEffectiveAccessFetchFailure) { + toast.error( + "Failed to check effective access. Showing unknown state.", + { id: "subject-permissions-effective-access-failed" } + ); + } + + return next; + }, + keepPreviousData: true + }); + + return { + effectiveAccessByAction, + isCheckingEffectiveAccess: isFetching + }; +} diff --git a/src/components/Permissions/SubjectPermissions/usePermissionResources.ts b/src/components/Permissions/SubjectPermissions/usePermissionResources.ts new file mode 100644 index 0000000000..cceb60b734 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/usePermissionResources.ts @@ -0,0 +1,101 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { getAll } from "@flanksource-ui/api/schemaResources"; +import { Connection } from "@flanksource-ui/components/Connections/ConnectionFormModal"; +import { SchemaApi } from "@flanksource-ui/components/SchemaResourcePage/resourceTypes"; +import { + PermissionResource, + RESOURCE_KIND_CONFIG, + sortPermissionResources +} from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +const connectionsSchema: SchemaApi = { + table: "connections", + api: "canary-checker", + name: "Connections" +}; + +export default function usePermissionResources() { + const { data: playbooks = [], isLoading: isLoadingPlaybooks } = useQuery({ + queryKey: ["permissions-subjects", "playbooks"], + queryFn: getAllPlaybookNames, + staleTime: 60_000 + }); + + const { data: viewsResponse, isLoading: isLoadingViews } = useQuery({ + queryKey: ["permissions-subjects", "views"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 2000), + staleTime: 60_000 + }); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["permissions-subjects", "connections"], + queryFn: async () => { + const response = await getAll(connectionsSchema); + return (response.data ?? []) as unknown as Connection[]; + }, + staleTime: 60_000 + }); + + const views = useMemo(() => viewsResponse?.data ?? [], [viewsResponse?.data]); + + const resources = useMemo(() => { + const playbookResources = playbooks.map((playbook) => ({ + id: playbook.id, + kind: "playbook" as const, + name: playbook.name, + displayName: playbook.title || playbook.name, + namespace: playbook.namespace, + icon: playbook.icon || "playbook", + subtitle: playbook.namespace + ? `${playbook.namespace} · playbook` + : "playbook", + selectorKey: RESOURCE_KIND_CONFIG.playbook.selectorKey, + actions: RESOURCE_KIND_CONFIG.playbook.actions + })); + + const viewResources = views.map((view) => ({ + id: view.id, + kind: "view" as const, + name: view.name, + displayName: view.spec?.title || view.name, + namespace: view.namespace, + icon: view.spec?.icon || "workflow", + subtitle: view.namespace ? `${view.namespace} · view` : "view", + selectorKey: RESOURCE_KIND_CONFIG.view.selectorKey, + actions: RESOURCE_KIND_CONFIG.view.actions + })); + + const connectionResources = connections + .filter( + (connection): connection is Connection & { id: string; name: string } => + Boolean(connection.id && connection.name) + ) + .map((connection) => ({ + id: connection.id, + kind: "connection" as const, + name: connection.name, + displayName: connection.name, + namespace: connection.namespace, + icon: connection.type || "connection", + subtitle: connection.namespace + ? `${connection.namespace} · ${connection.type || "connection"}` + : connection.type || "connection", + selectorKey: RESOURCE_KIND_CONFIG.connection.selectorKey, + actions: RESOURCE_KIND_CONFIG.connection.actions + })); + + return sortPermissionResources([ + ...playbookResources, + ...viewResources, + ...connectionResources + ]); + }, [connections, playbooks, views]); + + return { + resources, + isLoading: isLoadingPlaybooks || isLoadingViews || isLoadingConnections + }; +} diff --git a/src/components/Permissions/SubjectPermissions/useSubjectPermissionAccess.ts b/src/components/Permissions/SubjectPermissions/useSubjectPermissionAccess.ts new file mode 100644 index 0000000000..f6ee7494e4 --- /dev/null +++ b/src/components/Permissions/SubjectPermissions/useSubjectPermissionAccess.ts @@ -0,0 +1,119 @@ +import { + addPermission, + deletePermission, + fetchSettingsManagedSubjectPermissions, + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + PermissionSubject, + updatePermission +} from "@flanksource-ui/api/services/permissions"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useUser } from "@flanksource-ui/context"; +import { mapSubjectType } from "@flanksource-ui/lib/permissions/mcpPermissionCardMappings"; +import { + AccessValue, + PermissionResource, + getRefsForPermission, + selectorRefMatchesExactResource, + sortCanonicalPermissions +} from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import { useCallback, useState } from "react"; + +type UseSubjectPermissionAccessProps = { + selectedSubject: PermissionSubject; + onPermissionsUpdated: () => Promise; +}; + +export default function useSubjectPermissionAccess({ + selectedSubject, + onPermissionsUpdated +}: UseSubjectPermissionAccessProps) { + const { user } = useUser(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const setPermissionAccess = useCallback( + async ( + resource: PermissionResource, + action: string, + access: AccessValue + ) => { + setIsSubmitting(true); + + try { + const latestPermissions = await fetchSettingsManagedSubjectPermissions( + selectedSubject.id + ); + const subjectType = mapSubjectType(selectedSubject.type); + + const matchingPermissions = sortCanonicalPermissions( + latestPermissions.filter((permission) => { + if ( + permission.action !== action || + permission.subject !== selectedSubject.id || + permission.subject_type !== subjectType || + !permission.id + ) { + return false; + } + + return getRefsForPermission(permission, resource.selectorKey).some( + (ref) => selectorRefMatchesExactResource(ref, resource) + ); + }) + ); + + if (access === "default") { + await Promise.all( + matchingPermissions.map((permission) => + deletePermission(permission.id!) + ) + ); + } else { + const deny = access === "deny"; + const [primary, ...duplicates] = matchingPermissions; + + if (!primary) { + await addPermission({ + action, + object_selector: { + [resource.selectorKey]: [ + { name: resource.name, namespace: resource.namespace } + ] + }, + subject: selectedSubject.id, + subject_type: subjectType, + deny, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + created_by: user?.id + } as any); + } else { + if (primary.deny !== deny) { + await updatePermission({ id: primary.id, deny } as any); + } + + if (duplicates.length > 0) { + await Promise.all( + duplicates.map((permission) => deletePermission(permission.id!)) + ); + } + } + } + + toastSuccess("Updated permission"); + await onPermissionsUpdated(); + } catch (error) { + toastError(error as any); + } finally { + setIsSubmitting(false); + } + }, + [onPermissionsUpdated, selectedSubject.id, selectedSubject.type, user?.id] + ); + + return { + isSubmitting, + setPermissionAccess + }; +} diff --git a/src/components/Permissions/SubjectPermissionsWorkbench.tsx b/src/components/Permissions/SubjectPermissionsWorkbench.tsx new file mode 100644 index 0000000000..7f19bd38c5 --- /dev/null +++ b/src/components/Permissions/SubjectPermissionsWorkbench.tsx @@ -0,0 +1,145 @@ +import { + fetchSettingsManagedSubjectPermissions, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import SubjectPermissionsHeader from "@flanksource-ui/components/Permissions/SubjectPermissions/SubjectPermissionsHeader"; +import SubjectPermissionsMatrixContent from "@flanksource-ui/components/Permissions/SubjectPermissions/SubjectPermissionsMatrixContent"; +import { + PermissionResource, + getDirectAccessState, + getResourceActionKey, + groupResourcesByType, + isSamePermissionResource +} from "@flanksource-ui/components/Permissions/SubjectPermissions/shared"; +import useEffectiveSubjectAccess from "@flanksource-ui/components/Permissions/SubjectPermissions/useEffectiveSubjectAccess"; +import usePermissionResources from "@flanksource-ui/components/Permissions/SubjectPermissions/usePermissionResources"; +import useSubjectPermissionAccess from "@flanksource-ui/components/Permissions/SubjectPermissions/useSubjectPermissionAccess"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export default function SubjectPermissionsWorkbench({ + selectedSubject +}: { + selectedSubject: PermissionSubject; +}) { + const [search, setSearch] = useState(""); + const [selectedResource, setSelectedResource] = + useState(null); + const scrollRef = useRef(null); + + const { resources: allResources, isLoading: isLoadingResources } = + usePermissionResources(); + + const { + data: permissions = [], + isLoading: isLoadingPermissions, + refetch: refetchPermissions + } = useQuery({ + queryKey: [ + "permissions-subjects", + "subject-managed-permissions", + selectedSubject.id + ], + queryFn: () => fetchSettingsManagedSubjectPermissions(selectedSubject.id), + keepPreviousData: true + }); + + const { isSubmitting, setPermissionAccess } = useSubjectPermissionAccess({ + selectedSubject, + onPermissionsUpdated: refetchPermissions + }); + + const directAccessByResourceAction = useMemo(() => { + return Object.fromEntries( + allResources.flatMap((resource) => + resource.actions.map((action) => [ + getResourceActionKey(resource, action), + getDirectAccessState(permissions, selectedSubject, resource, action) + ]) + ) + ); + }, [allResources, permissions, selectedSubject]); + + const normalizedSearch = search.trim().toLowerCase(); + + const filteredResources = useMemo(() => { + return allResources.filter((resource) => { + if (!normalizedSearch) { + return true; + } + + return `${resource.displayName} ${resource.name} ${resource.namespace || ""}` + .toLowerCase() + .includes(normalizedSearch); + }); + }, [allResources, normalizedSearch]); + + const groupedResources = useMemo( + () => groupResourcesByType(filteredResources), + [filteredResources] + ); + + const { effectiveAccessByAction, isCheckingEffectiveAccess } = + useEffectiveSubjectAccess({ + selectedSubjectId: selectedSubject.id, + resources: filteredResources + }); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + setSelectedResource(null); + }, [selectedSubject.id]); + + useEffect(() => { + if ( + selectedResource && + !filteredResources.some((resource) => + isSamePermissionResource(selectedResource, resource) + ) + ) { + setSelectedResource(null); + } + }, [filteredResources, selectedResource]); + + const handleToggleResource = useCallback((resource: PermissionResource) => { + setSelectedResource((current) => + isSamePermissionResource(current, resource) ? null : resource + ); + }, []); + + const handlePermissionAccessChange = useCallback( + ( + resource: PermissionResource, + action: string, + access: "allow" | "deny" | "default" + ) => { + void setPermissionAccess(resource, action, access); + }, + [setPermissionAccess] + ); + + const loading = isLoadingResources || isLoadingPermissions; + + return ( +
+ + + +
+ ); +} diff --git a/src/lib/permissions/mcpPermissionCardMappings.ts b/src/lib/permissions/mcpPermissionCardMappings.ts index 9421d268fb..aa8567962c 100644 --- a/src/lib/permissions/mcpPermissionCardMappings.ts +++ b/src/lib/permissions/mcpPermissionCardMappings.ts @@ -1,6 +1,6 @@ import { - PermissionSubject, - MCP_SETTINGS_PERMISSION_SOURCE + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + PermissionSubject } from "@flanksource-ui/api/services/permissions"; import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; @@ -114,7 +114,7 @@ export function buildPermissionAccessCardMaps< permissions, getRefs, action = "mcp:run", - source = MCP_SETTINGS_PERMISSION_SOURCE, + source = INTERACTIVE_SETTINGS_PERMISSION_SOURCE, everyoneSubjectId = "everyone", everyoneSubjectType = "group" }: { diff --git a/src/lib/permissions/useMcpResourcePermissions.ts b/src/lib/permissions/useMcpResourcePermissions.ts index f4212dd093..2aeae19f3e 100644 --- a/src/lib/permissions/useMcpResourcePermissions.ts +++ b/src/lib/permissions/useMcpResourcePermissions.ts @@ -1,7 +1,8 @@ import { addPermission, deletePermission, - MCP_SETTINGS_PERMISSION_SOURCE, + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + isSettingsManagedPermissionSource, PermissionSubject, updatePermission } from "@flanksource-ui/api/services/permissions"; @@ -84,7 +85,7 @@ export function useMcpResourcePermissions< resources, permissions, getRefs, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, everyoneSubjectId: EVERYONE_SUBJECT_ID, everyoneSubjectType: EVERYONE_SUBJECT_TYPE }), @@ -106,7 +107,7 @@ export function useMcpResourcePermissions< for (const permission of permissions) { if ( !permission.subject || - permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + !isSettingsManagedPermissionSource(permission.source) || (permission.subject_type !== "person" && permission.subject_type !== "team" && permission.subject_type !== "group" && @@ -148,7 +149,7 @@ export function useMcpResourcePermissions< p.subject_type === EVERYONE_SUBJECT_TYPE && p.subject === EVERYONE_SUBJECT_ID && p.id && - p.source === MCP_SETTINGS_PERMISSION_SOURCE && + isSettingsManagedPermissionSource(p.source) && permissionMatchesResource(p, resource, getRefs) ); @@ -186,7 +187,7 @@ export function useMcpResourcePermissions< subject: EVERYONE_SUBJECT_ID, subject_type: EVERYONE_SUBJECT_TYPE, deny: targetDeny, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user?.id } as any); }, @@ -241,7 +242,7 @@ export function useMcpResourcePermissions< p.subject_type === "team" || p.subject_type === "group" || p.subject_type === "role") && - p.source === MCP_SETTINGS_PERMISSION_SOURCE && + isSettingsManagedPermissionSource(p.source) && permissionMatchesResource(p, resource, getRefs) ); @@ -271,7 +272,7 @@ export function useMcpResourcePermissions< subject: selection.subject.id, subject_type: subjectType, deny: selection.access === "deny", - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user?.id }; }) @@ -424,7 +425,7 @@ export function useMcpResourcePermissions< p.subject === subject.id && p.subject_type === subjectType && p.id && - p.source === MCP_SETTINGS_PERMISSION_SOURCE && + isSettingsManagedPermissionSource(p.source) && permissionMatchesResource(p, resource, getRefs) ); @@ -451,7 +452,7 @@ export function useMcpResourcePermissions< subject: subject.id, subject_type: subjectType, deny: shouldDeny, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user?.id } as any); } else if (primary.deny !== shouldDeny) { diff --git a/src/pages/Settings/PermissionsSubjectsPage.tsx b/src/pages/Settings/PermissionsSubjectsPage.tsx index d68e3765b4..f93f9694e3 100644 --- a/src/pages/Settings/PermissionsSubjectsPage.tsx +++ b/src/pages/Settings/PermissionsSubjectsPage.tsx @@ -2,10 +2,11 @@ import { fetchAllPermissionSubjects } from "@flanksource-ui/api/services/permiss import PermissionSubjectPanel, { PermissionSubjectGroup } from "@flanksource-ui/components/Permissions/PermissionSubjectPanel"; +import SubjectPermissionsWorkbench from "@flanksource-ui/components/Permissions/SubjectPermissionsWorkbench"; import PermissionsTabsLinks from "@flanksource-ui/components/Permissions/PermissionsTabsLinks"; import useDebouncedValue from "@flanksource-ui/hooks/useDebounce"; -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; const SUBJECT_TYPE_ORDER = { role: 0, @@ -23,6 +24,8 @@ export function PermissionsSubjectsPage() { const debouncedSearch = useDebouncedValue(subjectSearch, 200) ?? ""; + const queryClient = useQueryClient(); + const { data: subjects = [], isLoading, @@ -88,22 +91,28 @@ export function PermissionsSubjectsPage() { [selectedSubjectId, sortedSubjects] ); + const isWorkbenchFetching = + useIsFetching({ queryKey: ["permissions-subjects"] }) > 0; + + const onRefresh = useCallback(async () => { + await Promise.all([ + refetch(), + queryClient.invalidateQueries({ queryKey: ["permissions-subjects"] }) + ]); + }, [queryClient, refetch]); + return ( refetch()} + loading={isLoading || isRefetching || isWorkbenchFetching} + onRefresh={onRefresh} > -
-
-

Subjects

-

- Browse permission subjects. Subject details and actions will be - added here. -

-
+
+
+
+

Subjects

+
-
+
-
+
+ {selectedSubject ? ( + + ) : (
- {selectedSubject - ? `Selected subject: ${selectedSubject.name}` - : "Select a subject."} + Select a subject.
-
+ )}
diff --git a/src/pages/Settings/mcp/McpOverviewPage.tsx b/src/pages/Settings/mcp/McpOverviewPage.tsx index 87fce8321b..221bad9fdf 100644 --- a/src/pages/Settings/mcp/McpOverviewPage.tsx +++ b/src/pages/Settings/mcp/McpOverviewPage.tsx @@ -3,7 +3,8 @@ import { deletePermission, fetchAllPermissionSubjects, fetchMcpUserPermissions, - MCP_SETTINGS_PERMISSION_SOURCE, + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + isSettingsManagedPermissionSource, PermissionSubject, updatePermission } from "@flanksource-ui/api/services/permissions"; @@ -36,7 +37,7 @@ function isMcpUserAccessPermission(permission: PermissionsSummary) { permission.object === MCP_OBJECT && !!permission.subject && !!permission.id && - permission.source === MCP_SETTINGS_PERMISSION_SOURCE + isSettingsManagedPermissionSource(permission.source) ); } @@ -143,7 +144,7 @@ export default function McpOverviewPage() { .filter( (permission) => permission.subject === subjectId && - permission.source === MCP_SETTINGS_PERMISSION_SOURCE + isSettingsManagedPermissionSource(permission.source) ); if (access === "default") { @@ -172,7 +173,7 @@ export default function McpOverviewPage() { subject: subjectId, subject_type: mapPermissionSubjectType(subjectType), deny: targetDeny, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user.id } as any); diff --git a/src/pages/Settings/mcp/McpSubjectAccessPage.tsx b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx index ddeeda5937..7e7f85dd58 100644 --- a/src/pages/Settings/mcp/McpSubjectAccessPage.tsx +++ b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx @@ -4,7 +4,8 @@ import { deletePermission, fetchAllPermissionSubjects, fetchMcpRunPermissions, - MCP_SETTINGS_PERMISSION_SOURCE, + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + isSettingsManagedPermissionSource, PermissionSubject, updatePermission } from "@flanksource-ui/api/services/permissions"; @@ -197,7 +198,7 @@ export default function McpSubjectAccessPage() { return () => { clearTimeout(timer); }; - }, [selectedSubject?.id]); + }, [selectedSubject]); const resources = useMemo(() => { const playbookResources = playbooks @@ -327,7 +328,7 @@ export default function McpSubjectAccessPage() { const matchingPermissions = currentPermissions.filter( (permission) => permission.action === "mcp:run" && - permission.source === MCP_SETTINGS_PERMISSION_SOURCE && + isSettingsManagedPermissionSource(permission.source) && permission.subject === subject.id && permission.subject_type === subjectType && permission.id && @@ -357,7 +358,7 @@ export default function McpSubjectAccessPage() { subject: subject.id, subject_type: subjectType, deny, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user?.id } as any); return; @@ -404,7 +405,7 @@ export default function McpSubjectAccessPage() { const existingForKind = latestPermissions.filter((permission) => { if ( permission.action !== "mcp:run" || - permission.source !== MCP_SETTINGS_PERMISSION_SOURCE || + !isSettingsManagedPermissionSource(permission.source) || permission.subject !== selectedSubject.id || permission.subject_type !== subjectType || !permission.id @@ -442,7 +443,7 @@ export default function McpSubjectAccessPage() { subject: selectedSubject.id, subject_type: subjectType, deny, - source: MCP_SETTINGS_PERMISSION_SOURCE, + source: INTERACTIVE_SETTINGS_PERMISSION_SOURCE, created_by: user?.id } as any); } else if (primaryWildcard.deny !== deny) { @@ -520,13 +521,15 @@ export default function McpSubjectAccessPage() {
- +
+ +
{selectedSubject ? ( diff --git a/src/ui/Tabs/FlatTabs.tsx b/src/ui/Tabs/FlatTabs.tsx index c0dd74c37f..69cad98cd9 100644 --- a/src/ui/Tabs/FlatTabs.tsx +++ b/src/ui/Tabs/FlatTabs.tsx @@ -19,7 +19,7 @@ export default function FlatTabs({ contentClassName = "px-4 pb-4" }: FlatTabsProps) { return ( -
+
); } From 099f7844af9d9fdfbba4b1f172c06c69387eb275 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 23 Apr 2026 09:58:36 +0000 Subject: [PATCH 67/72] chore(release): 1.4.242 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a662b5424e..63843493f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.241", + "version": "1.4.242", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index d33b7a4af6..465aa55f6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.241", + "version": "1.4.242", "private": false, "files": [ "build", From c66675609a9a8fa183c91faf6e7b9b65a57b2b85 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 23 Apr 2026 15:42:47 +0545 Subject: [PATCH 68/72] fix(feature-flags): refetch settings flags on page mount The feature flags settings page could show stale values when revisited because it reused a shared cached query result. Users had to manually refresh to see backend updates. Allow useGetFeatureFlagsFromAPI to accept optional query options and set refetchOnMount="always" in FeatureFlagsPage so each visit revalidates data immediately. Also includes feature flag list table layout tweaks from current staged changes (column sizing and removal of description column). --- src/api/query-hooks/useFeatureFlags.tsx | 7 +++++-- src/components/FeatureFlags/FeatureFlagList.tsx | 17 +++++++++-------- src/pages/Settings/FeatureFlagsPage.tsx | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/api/query-hooks/useFeatureFlags.tsx b/src/api/query-hooks/useFeatureFlags.tsx index eac3862515..27edad65e7 100644 --- a/src/api/query-hooks/useFeatureFlags.tsx +++ b/src/api/query-hooks/useFeatureFlags.tsx @@ -43,14 +43,17 @@ export function useGetPropertyFromDB(featureFlag?: FeatureFlag) { }); } -export function useGetFeatureFlagsFromAPI() { +export function useGetFeatureFlagsFromAPI(options?: { + refetchOnMount?: boolean | "always"; +}) { return useQuery({ queryKey: ["feature-flags", "all", "api"], queryFn: async () => { const res = await fetchFeatureFlagsAPI(); return res.data ?? []; }, - staleTime: 30 * 1000 + staleTime: 30 * 1000, + ...options }); } diff --git a/src/components/FeatureFlags/FeatureFlagList.tsx b/src/components/FeatureFlags/FeatureFlagList.tsx index fb8c0b938d..9b5a8b9f44 100644 --- a/src/components/FeatureFlags/FeatureFlagList.tsx +++ b/src/components/FeatureFlags/FeatureFlagList.tsx @@ -22,11 +22,8 @@ const AvatarCell = ({ getValue }: CellContext) => { const columns: ColumnDef[] = [ { header: "Name", - accessorKey: "name" - }, - { - header: "Description", - accessorKey: "description" + accessorKey: "name", + minSize: 300 }, { header: "Value", @@ -34,23 +31,27 @@ const columns: ColumnDef[] = [ }, { header: "Source", - accessorKey: "source" + accessorKey: "source", + maxSize: 80 }, { header: "Created By", accessorKey: "created_by", - cell: AvatarCell + cell: AvatarCell, + maxSize: 80 }, { header: "Created At", accessorKey: "created_at", cell: DateCell, - sortingFn: "datetime" + sortingFn: "datetime", + maxSize: 120 }, { header: "Updated At", accessorKey: "updated_at", cell: DateCell, + maxSize: 120, sortingFn: "datetime" } ]; diff --git a/src/pages/Settings/FeatureFlagsPage.tsx b/src/pages/Settings/FeatureFlagsPage.tsx index d3c8808579..02815194d2 100644 --- a/src/pages/Settings/FeatureFlagsPage.tsx +++ b/src/pages/Settings/FeatureFlagsPage.tsx @@ -33,7 +33,7 @@ export function FeatureFlagsPage() { data: featureFlags, isLoading, refetch - } = useGetFeatureFlagsFromAPI(); + } = useGetFeatureFlagsFromAPI({ refetchOnMount: "always" }); const { mutate: saveFeatureFlag } = useAddFeatureFlag(() => { refetch(); From a4d8975d801302eb7e8ace0c071a7ae8f049d020 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 23 Apr 2026 10:54:13 +0000 Subject: [PATCH 69/72] chore(release): 1.4.243 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 63843493f1..2c9fa23c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.242", + "version": "1.4.243", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 465aa55f6c..b9e3d01294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.242", + "version": "1.4.243", "private": false, "files": [ "build", From eca25d73e64ce36894d276977760ecd2e6ff0a20 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 23 Apr 2026 17:06:28 +0545 Subject: [PATCH 70/72] feat: improve notification rules table event badges and tooltip behavior --- .../Rules/notificationsRulesTableColumns.tsx | 159 ++++++++++++++---- src/components/ui/tooltip.tsx | 2 +- 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/components/Notifications/Rules/notificationsRulesTableColumns.tsx b/src/components/Notifications/Rules/notificationsRulesTableColumns.tsx index 0c055d6ec3..8ffad90ac7 100644 --- a/src/components/Notifications/Rules/notificationsRulesTableColumns.tsx +++ b/src/components/Notifications/Rules/notificationsRulesTableColumns.tsx @@ -1,15 +1,20 @@ import { NotificationRules } from "@flanksource-ui/api/types/notifications"; -import { Badge } from "@flanksource-ui/ui/Badge/Badge"; +import { Badge } from "@flanksource-ui/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@flanksource-ui/components/ui/tooltip"; import MRTAvatarCell from "@flanksource-ui/ui/MRTDataTable/Cells/MRTAvataCell"; import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; import { formatDuration, age } from "@flanksource-ui/utils/date"; import dayjs from "dayjs"; import { MRT_ColumnDef } from "mantine-react-table"; -import { useState, useId } from "react"; +import { useState } from "react"; import JobHistoryStatusColumn from "../../JobsHistory/JobHistoryStatusColumn"; import { JobsHistoryDetails } from "../../JobsHistory/JobsHistoryDetails"; -import { Tooltip } from "react-tooltip"; import { Status } from "../../Status"; export const notificationEvents = [ @@ -76,6 +81,42 @@ export const notificationEvents = [ } ].sort((a, b) => a.label.localeCompare(b.label)); +function WrappedHeader({ title }: { title: string }) { + return {title}; +} + +function getEventBadgeClasses(event: string) { + if ( + event.endsWith(".failed") || + event.endsWith(".unhealthy") || + event.endsWith(".deleted") + ) { + return "border-red-200 bg-red-50 text-red-700"; + } + + if (event.endsWith(".warning")) { + return "border-amber-200 bg-amber-50 text-amber-700"; + } + + if (event.endsWith(".unknown")) { + return "border-slate-200 bg-slate-100 text-slate-700"; + } + + if (event.endsWith(".passed") || event.endsWith(".healthy")) { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + + if (event.endsWith(".created") || event.endsWith(".updated")) { + return "border-blue-200 bg-blue-50 text-blue-700"; + } + + if (event.startsWith("playbook.")) { + return "border-violet-200 bg-violet-50 text-violet-700"; + } + + return "border-muted-foreground/20 bg-muted text-foreground"; +} + export function StatusColumn({ cell }: MRTCellProps) { const [isModalOpen, setIsModalOpen] = useState(false); const value = cell.row.original.job_status; @@ -110,42 +151,89 @@ export type UpdateNotificationRule = Omit< export const notificationsRulesTableColumns: MRT_ColumnDef[] = [ + { + header: "Name", + id: "name", + size: 150, + accessorKey: "name" + }, { header: "Events", id: "events", accessorKey: "events", size: 150, Cell: ({ row, column }) => { - const value = row.getValue(column.id); + const value = + row.getValue(column.id) ?? []; + const visibleEvents = value.slice(0, 2); + const hiddenCount = value.length - visibleEvents.length; return ( -
- {value.map((event) => ( -
- -
+
+ {visibleEvents.map((event) => ( + + {event} + ))} + {hiddenCount > 0 && ( + + + + + + +{hiddenCount} + + + + + {value.slice(2).join(", ")} + + + + )}
); } }, - { - header: "Name", - id: "name", - size: 150, - accessorKey: "name" - }, { header: "Filter", id: "filter", size: 100, - accessorKey: "filter" + accessorKey: "filter", + Cell: ({ row, column }) => { + const value = row.getValue(column.id) ?? ""; + + if (!value) { + return null; + } + + return ( + + + + {value} + + + {value} + + + + ); + } }, { header: "Status", id: "status", accessorKey: "error", - size: 100, + size: 80, Cell: ({ row }) => { const error = row.original.error; return ( @@ -160,6 +248,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Sent / Failed / Pending", + Header: () => , id: "sent_failed_pending", size: 200, Cell: ({ row }) => { @@ -168,7 +257,6 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] const pending = row.original.pending ?? 0; const mostCommonError = row.original.most_common_error ?? ""; const errorAt = row.original.error_at; - const tooltipId = useId(); const tooltipContent = errorAt ? `${age(errorAt)} ago: ${mostCommonError}` @@ -179,18 +267,26 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] {sent > 0 && ( )} - {failed > 0 && ( -
+ {failed > 0 && + (tooltipContent ? ( + + + +
+ +
+
+ + {tooltipContent} + +
+
+ ) : ( - {tooltipContent && ( - - )} -
- )} + ))} {pending > 0 && ( )} @@ -200,6 +296,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Avg Duration", + Header: () => , id: "avg_duration_ms", accessorKey: "avg_duration_ms", size: 80, @@ -214,6 +311,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Repeat Interval", + Header: () => , id: "repeat_interval", accessorKey: "repeat_interval", size: 80, @@ -238,6 +336,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Created At", + Header: () => , id: "created_at", accessorKey: "created_at", size: 100, @@ -245,6 +344,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Updated At", + Header: () => , id: "updated_at", accessorKey: "updated_at", size: 100, @@ -252,6 +352,7 @@ export const notificationsRulesTableColumns: MRT_ColumnDef[] }, { header: "Created By", + Header: () => , id: "created_by", accessorKey: "created_by", size: 100, diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 6a161b456d..c027dd4e8b 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-[#222] px-3 py-1.5 text-xs text-primary-foreground text-white animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} From 51dad2bbdf061454558b68d93d1bcef45de796d9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 23 Apr 2026 11:29:23 +0000 Subject: [PATCH 71/72] chore(release): 1.4.244 [skip ci] --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c9fa23c51..52d8a307fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.243", + "version": "1.4.244", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index b9e3d01294..fb60b2af4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.243", + "version": "1.4.244", "private": false, "files": [ "build", From a72a98c3a44fd37a6aadb1c050659de4ae48316a Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 23 Apr 2026 21:48:17 +0545 Subject: [PATCH 72/72] fix(connections): show URL field and fix insecure_tls mapping in AWS forms AWS, AWS KMS, and AWS S3 connection forms were missing the URL input field. Additionally, insecure_tls was being read from and written to the properties JSON blob instead of the dedicated top-level DB column. - Added URL field to AWS and AWS S3 form field definitions - insecure_tls in convertToFormSpecificValue now reads only from the top-level data field - preSubmitConverter now sets insecure_tls at top level and removes it from properties --- .../Connections/connectionTypes.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/Connections/connectionTypes.tsx b/src/components/Connections/connectionTypes.tsx index 2b4e5d4d59..d094de5929 100644 --- a/src/components/Connections/connectionTypes.tsx +++ b/src/components/Connections/connectionTypes.tsx @@ -887,6 +887,12 @@ export const connectionTypes: ConnectionType[] = [ value: ConnectionValueType.AWS, fields: [ ...commonConnectionFormFields, + { + label: "URL", + key: "url", + type: ConnectionsFieldTypes.input, + required: false + }, { label: "Region", key: "region", @@ -922,18 +928,20 @@ export const connectionTypes: ConnectionType[] = [ ...data, region: data?.properties?.region, profile: data?.properties?.profile, - insecure_tls: data?.properties?.insecureTLS === "true" + insecure_tls: + data?.insecure_tls === true || data?.insecure_tls === "true" } as Connection; }, preSubmitConverter: (data: Record) => { return { name: data.name, + url: data.url, username: data.username, password: data.password, + insecure_tls: data.insecure_tls, properties: { region: data.region, - profile: data.profile, - insecureTLS: data.insecure_tls + profile: data.profile } }; } @@ -986,7 +994,8 @@ export const connectionTypes: ConnectionType[] = [ region: data?.properties?.region, profile: data?.properties?.profile, keyID: data?.properties?.keyID ?? data?.keyID, - insecure_tls: data?.properties?.insecureTLS === "true" + insecure_tls: + data?.insecure_tls === true || data?.insecure_tls === "true" } as Connection; }, preSubmitConverter: (data: Record) => { @@ -994,11 +1003,11 @@ export const connectionTypes: ConnectionType[] = [ name: data.name, username: data.username, password: data.password, + insecure_tls: data.insecure_tls, properties: { region: data.region, profile: data.profile, - keyID: data.keyID, - insecureTLS: data.insecure_tls + keyID: data.keyID } }; } @@ -1009,6 +1018,12 @@ export const connectionTypes: ConnectionType[] = [ value: ConnectionValueType.AWS_S3, fields: [ ...commonConnectionFormFields, + { + label: "URL", + key: "url", + type: ConnectionsFieldTypes.input, + required: false + }, { label: "Region", key: "region", @@ -1050,13 +1065,15 @@ export const connectionTypes: ConnectionType[] = [ ...data, region: data?.properties?.region, profile: data?.properties?.profile, - insecure_tls: data?.insecure_tls === true, + insecure_tls: + data?.insecure_tls === true || data?.insecure_tls === "true", bucket: data?.properties?.bucket } as Connection; }, preSubmitConverter: (data: Record) => { return { name: data.name, + url: data.url, username: data.username, password: data.password, insecure_tls: !!data.insecure_tls,