diff --git a/.agents/skills/data-table-builder/SKILL.md b/.agents/skills/data-table-builder/SKILL.md new file mode 100644 index 0000000000..f909f8c647 --- /dev/null +++ b/.agents/skills/data-table-builder/SKILL.md @@ -0,0 +1,8 @@ +--- +name: data-table-builder +description: Read when working with filters in the data table +--- + +- Never query the entire table and then filter client-side to populate the filter dropdown options. + Create a view in duty/ repo instead that returns options for all the filters at once. + Reference: `config_access_summary_by_user` view in views/038_config_access.sql diff --git a/AGENTS.md b/AGENTS.md index d553b4e492..fcea06c7d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,2 @@ - Do not hand-write Shadcn components. Use the shadcn CLI to add new components. -- Use npm run build to build +- Do not run `npm run build` when dev server is running. use `npm run typecheck` instead. 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" + } + }); } } diff --git a/clerk.middleware.ts b/clerk.middleware.ts index ce875a5d59..fedc1ebcef 100644 --- a/clerk.middleware.ts +++ b/clerk.middleware.ts @@ -4,7 +4,18 @@ import { NextResponse } from "next/server"; const isPubliclyAccessibleRoute = createRouteMatcher([ // all pages except the ones listed below are protected "/login(.*)", - "/registration(.*)" + "/registration(.*)", + "/.well-known(.*)", + "/authorize(.*)", + "/oauth(.*)", + "/userinfo", + "/revoke", + "/device_authorization", + "/keys", + "/end_session", + "/endsession", + "/oidc(.*)", + "/mcp(.*)" ]); export default clerkMiddleware( diff --git a/next.config.js b/next.config.js index 1aef517c59..2363ce3ced 100644 --- a/next.config.js +++ b/next.config.js @@ -39,22 +39,61 @@ const config = { ]; }, async rewrites() { - // if clerk is enabled, we will use next API routes to proxy requests to - // the backend - if (process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true") { - return []; - } + const isClerkAuth = process.env.NEXT_PUBLIC_AUTH_IS_CLERK === "true"; + const isBasicAuth = process.env.NEXT_PUBLIC_AUTH_IS_BASIC === "true"; // Read at build time. See Dockerfile for deployment related steps. const backendURL = process.env.BACKEND_URL || "http://localhost:3000/"; const isCanary = process.env.NEXT_PUBLIC_APP_DEPLOYMENT === "CANARY_CHECKER"; const canaryPrefix = isCanary ? "" : "/canary"; + // OIDC protocol endpoints are mounted at the root of the backend (matching the + // issuer URL). These rewrites let the browser reach those endpoints through the + // Next.js server without authentication interference. + const OIDC_REWRITES = [ + { + source: "/.well-known/:path*", + destination: `${backendURL}/.well-known/:path*` + }, + { source: "/authorize", destination: `${backendURL}/authorize` }, + { + source: "/authorize/:path*", + destination: `${backendURL}/authorize/:path*` + }, + { source: "/oauth/token", destination: `${backendURL}/oauth/token` }, + { + source: "/oauth/introspect", + destination: `${backendURL}/oauth/introspect` + }, + { source: "/userinfo", destination: `${backendURL}/userinfo` }, + { source: "/revoke", destination: `${backendURL}/revoke` }, + { + source: "/device_authorization", + destination: `${backendURL}/device_authorization` + }, + { source: "/keys", destination: `${backendURL}/keys` }, + { source: "/end_session", destination: `${backendURL}/end_session` }, + // Some clients use /endsession (no underscore) — proxy both. + { source: "/endsession", destination: `${backendURL}/endsession` }, + // All OIDC sub-routes (login, callback, …) + { source: "/oidc/:path*", destination: `${backendURL}/oidc/:path*` }, + // MCP transport endpoint + { source: "/mcp", destination: `${backendURL}/mcp` }, + { source: "/mcp/:path*", destination: `${backendURL}/mcp/:path*` } + ]; + + // clerk and basic auth use next API routes for app endpoints, but OIDC protocol + // endpoints still need explicit rewrites. + if (isClerkAuth || isBasicAuth) { + return OIDC_REWRITES; + } + const LOCALHOST_ENV_URL_REWRITES = [ { source: "/api/:path*", destination: `${backendURL}/api/:path*` - } + }, + ...OIDC_REWRITES ]; const URL_REWRITES = [ @@ -71,7 +110,8 @@ const config = { { source: "/api/:path*", destination: `${backendURL}/:path*` - } + }, + ...OIDC_REWRITES ]; // NODE_ENV is set to "development" when running locally, so we can use it // to determine if we are running in a local environment. diff --git a/package-lock.json b/package-lock.json index 273fa2a892..52d8a307fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.217", + "version": "1.4.244", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@flanksource/flanksource-ui", - "version": "1.4.215", + "version": "1.4.232", "dependencies": { "@ai-sdk/anthropic": "^3.0.1", "@ai-sdk/mcp": "^1.0.1", @@ -40,6 +40,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@storybook/client-api": "^7.6.17", @@ -7655,6 +7656,106 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", diff --git a/package.json b/package.json index aa798ff7df..fb60b2af4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flanksource/flanksource-ui", - "version": "1.4.217", + "version": "1.4.244", "private": false, "files": [ "build", @@ -40,6 +40,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@storybook/client-api": "^7.6.17", diff --git a/src/App.tsx b/src/App.tsx index 1eb219a13b..71cf0233c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,12 @@ import { UserAccessStateContextProvider } from "./context/UserAccessContext/User import { tables } from "./context/UserAccessContext/permissions"; import { PermissionsPage } from "./pages/Settings/PermissionsPage"; +import { PermissionsSubjectsPage } from "./pages/Settings/PermissionsSubjectsPage"; +import McpOverviewPage from "./pages/Settings/mcp/McpOverviewPage"; +import McpPlaybooksPage from "./pages/Settings/mcp/McpPlaybooksPage"; +import McpViewsPage from "./pages/Settings/mcp/McpViewsPage"; +import McpSubjectAccessPage from "./pages/Settings/mcp/McpSubjectAccessPage"; +import McpCheckAccessPage from "./pages/Settings/mcp/McpCheckAccessPage"; import ScopesPage from "./pages/Settings/ScopesPage"; import { features } from "./services/permissions/features"; import { getViewsForSidebar, ViewSummary } from "./api/services/views"; @@ -139,6 +145,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 @@ -274,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") ); @@ -421,6 +437,15 @@ const settingsNav: SettingsNavigationItems = { featureName: features["settings.job_history"], resourceName: tables.database }, + { + name: "MCP", + href: "/settings/mcp", + icon: ({ className }: { className: string }) => ( + + ), + featureName: features["settings.mcp"], + resourceName: tables.database + }, { name: "Feature Flags", href: "/settings/feature-flags", @@ -729,6 +754,14 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { "read" )} /> + , + tables.permissions, + "read" + )} + /> + , + tables.database, + "read", + true + )} + /> + + } /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + , + tables.database, + "write", + true + )} + /> + + {settingsNav.submenu .filter((v) => (v as SchemaResourceType).table) .map((x) => { @@ -909,6 +1000,15 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { true )} /> + , + tables.database, + "read", + true + )} + /> { + const payload = getImpersonatedPayload(); + if (payload) { + config.headers["X-Flanksource-Scope"] = JSON.stringify(payload); + } + return config; + }); +} + export function redirectToLoginPageOnSessionExpiry(error: AxiosError) { if (error?.response?.status === 401) { if (isClerkAuthSystem) { diff --git a/src/api/query-hooks/useAllConfigAccessSummaryQuery.ts b/src/api/query-hooks/useAllConfigAccessSummaryQuery.ts new file mode 100644 index 0000000000..e9464a7a18 --- /dev/null +++ b/src/api/query-hooks/useAllConfigAccessSummaryQuery.ts @@ -0,0 +1,69 @@ +import { + CATALOG_ACCESS_FLAT_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 { UseQueryOptions, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { getConfigAccessSummary } from "../services/configAccess"; + +type ConfigAccessSummaryResponse = Awaited< + ReturnType +>; + +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/query-hooks/useConfigAnalysisQuery.ts b/src/api/query-hooks/useConfigAnalysisQuery.ts index d9ef26d85e..19b513888b 100644 --- a/src/api/query-hooks/useConfigAnalysisQuery.ts +++ b/src/api/query-hooks/useConfigAnalysisQuery.ts @@ -1,10 +1,6 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { AxiosResponseWithTotalEntries } from "../../types"; -import { - getAllConfigInsights, - getConfigAnalysisByComponent, - getConfigsByIDs -} from "../services/configs"; +import { getAllConfigInsights } from "../services/configs"; import { PaginationInfo } from "../types/common"; import { ConfigAnalysis } from "../types/configs"; @@ -14,9 +10,10 @@ export function useConfigInsightsQuery( severity?: string; type?: string; analyzer?: string; - component?: string; + source?: string; configId?: string; configType?: string; + catalogId?: string; }, sortBy: { sortBy?: string; @@ -30,41 +27,7 @@ export function useConfigInsightsQuery( ) { return useQuery, Error>( ["configs", "insights", pageInfo, queryParams, sortBy], - async () => { - if (queryParams.component) { - const res = await getConfigAnalysisByComponent(queryParams.component); - const insights = res.filter((item) => { - if (queryParams.status && item.status !== queryParams.status) { - return false; - } - if (queryParams.severity && item.severity !== queryParams.severity) { - return false; - } - if (queryParams.type && item.analysis_type !== queryParams.type) { - return false; - } - if (queryParams.analyzer && item.analyzer !== queryParams.analyzer) { - return false; - } - return true; - }); - const configIds = insights.map((item) => item.config_id); - const configs = await getConfigsByIDs(configIds); - return { - data: insights.map((item) => { - const config = configs.find( - (config) => config.id === item.config_id - ); - return { - ...item, - config - }; - }), - error: null - }; - } - return getAllConfigInsights(queryParams, sortBy, pageInfo); - }, + () => getAllConfigInsights(queryParams, sortBy, pageInfo), options ); } 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/query-hooks/useConfigInsightsFilterOptions.ts b/src/api/query-hooks/useConfigInsightsFilterOptions.ts new file mode 100644 index 0000000000..2127d675e8 --- /dev/null +++ b/src/api/query-hooks/useConfigInsightsFilterOptions.ts @@ -0,0 +1,25 @@ +import { + ConfigInsightsFilterOptions, + getConfigInsightsFilterOptions +} from "@flanksource-ui/api/services/configs"; +import { useQuery } from "@tanstack/react-query"; + +const emptyOptions: ConfigInsightsFilterOptions = { + types: [], + analyzers: [], + sources: [], + config_types: [], + catalogs: [] +}; + +/** + * Fetches all filter options for the insights table in a single RPC call. + * When configId is supplied, options are scoped to that config's analysis rows. + */ +export function useConfigInsightsFilterOptions(configId?: string) { + return useQuery({ + queryKey: ["config_analysis_filter_options", configId ?? null], + queryFn: () => getConfigInsightsFilterOptions(configId), + select: (data) => data ?? emptyOptions + }); +} 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/api/query-hooks/useFetchConfigInsights.tsx b/src/api/query-hooks/useFetchConfigInsights.tsx index 9e5ee7749b..3af36d758c 100644 --- a/src/api/query-hooks/useFetchConfigInsights.tsx +++ b/src/api/query-hooks/useFetchConfigInsights.tsx @@ -7,24 +7,28 @@ export default function useFetchConfigInsights( ) { const [params] = useSearchParams(); - const status = params.get("status") ?? undefined; + // Default status to "open" when not explicitly set in the URL + const status = params.get("status") ?? "open"; const severity = params.get("severity") ?? undefined; const type = params.get("type") ?? undefined; const configType = params.get("configType") ?? undefined; const analyzer = params.get("analyzer") ?? undefined; - const component = params.get("component") ?? undefined; + const source = params.get("source") ?? undefined; + const catalogId = params.get("catalogId") ?? undefined; const pageSize = +(params.get("pageSize") ?? 50); const pageIndex = +(params.get("pageIndex") ?? 0); return useConfigInsightsQuery( { status, - severity: severity?.toLowerCase(), + severity, type, analyzer, - component, + source, + // configId prop (config details page) takes precedence over catalogId URL param configId, - configType + configType, + catalogId: configId ? undefined : catalogId }, { sortBy: params.get("sortBy") ?? undefined, diff --git a/src/api/query-hooks/useJobsHistoryQuery.ts b/src/api/query-hooks/useJobsHistoryQuery.ts index c542cc81b0..19343b03a7 100644 --- a/src/api/query-hooks/useJobsHistoryQuery.ts +++ b/src/api/query-hooks/useJobsHistoryQuery.ts @@ -1,12 +1,25 @@ +import dayjs from "dayjs"; import { durationOptions } from "@flanksource-ui/components/JobsHistory/Filters/JobHistoryDurationDropdown"; import { jobHistoryDefaultDateFilter } from "@flanksource-ui/components/JobsHistory/Filters/JobsHistoryFilters"; import { JobHistory } from "@flanksource-ui/components/JobsHistory/JobsHistoryTable"; import { resourceTypeMap } from "@flanksource-ui/components/SchemaResourcePage/SchemaResourceEditJobsTab"; +import { parseDateMath } from "@flanksource-ui/ui/Dates/TimeRangePicker/parseDateMath"; import useTimeRangeParams from "@flanksource-ui/ui/Dates/TimeRangePicker/useTimeRangeParams"; +import { + mappedOptionsTimeRanges, + MappedOptionsDisplay +} from "@flanksource-ui/ui/Dates/TimeRangePicker/rangeOptions"; 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, + getJobsHistorySummary, + getJobsHistoryWithArtifacts, + GetJobsHistoryParams, + GetJobsHistorySummaryParams, + JobHistorySummary +} from "../services/jobsHistory"; type Response = | { error: Error; data: null; totalEntries: undefined } @@ -16,6 +29,14 @@ type Response = error: null; }; +type SummaryResponse = + | { error: Error; data: null; totalEntries: undefined } + | { + data: JobHistorySummary[] | null; + totalEntries?: number | undefined; + error: null; + }; + export function useJobsHistoryQuery( pageIndex: number, pageSize: number, @@ -31,16 +52,19 @@ export function useJobsHistoryQuery( export function useJobsHistoryForSettingQuery( options?: UseQueryOptions, 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") ?? ""; @@ -48,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; @@ -65,6 +93,70 @@ export function useJobsHistoryForSettingQuery( status, sortBy, sortOrder, + duration: durationMillis, + resourceId + } satisfies Omit; + + return useQuery( + ["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 + ); +} + +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, @@ -72,8 +164,34 @@ export function useJobsHistoryForSettingQuery( } satisfies GetJobsHistoryParams; return useQuery( - ["jobs_history", params], - () => getJobsHistory(params), + ["jobs_history", "scraper", params], + () => getJobsHistoryWithArtifacts(params), + 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/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/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..8648c265f4 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; @@ -262,6 +230,7 @@ export type GetConfigsRelatedChangesParams = { severity?: string; from?: string; to?: string; + from_inserted_at?: string; configTypes?: string; configType?: string; pageSize?: number | string; @@ -284,6 +253,7 @@ export async function getConfigsChanges({ severity, from, to, + from_inserted_at, configTypes, configType, pageIndex, @@ -334,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); } @@ -658,6 +631,51 @@ export type ConfigAnalysisTypeItem = { analysis_type: string; }; +// --------------------------------------------------------------------------- +// config_analysis_filter_options RPC +// --------------------------------------------------------------------------- + +export type ConfigInsightsCatalogOption = { + id: string; + name: string; + type: string; + config_class: string; +}; + +export type ConfigInsightsFilterOptions = { + types: string[]; + analyzers: string[]; + sources: string[]; + config_types: string[]; + catalogs: ConfigInsightsCatalogOption[]; +}; + +const emptyInsightsFilterOptions: ConfigInsightsFilterOptions = { + types: [], + analyzers: [], + sources: [], + config_types: [], + catalogs: [] +}; + +/** + * Calls the config_analysis_filter_options stored procedure which returns all + * distinct filter values in a single round-trip. When configId is provided the + * results are scoped to that config's analysis rows. + */ +export const getConfigInsightsFilterOptions = async ( + configId?: string +): Promise => { + const params = new URLSearchParams(); + if (configId) params.set("p_config_id", configId); + + const res = await ConfigDB.get( + `/rpc/config_analysis_filter_options?${params.toString()}` + ); + + return res.data ?? emptyInsightsFilterOptions; +}; + export const getConfigsAnalysisTypesFilter = async () => { const res = await IncidentCommander.get( `/analysis_types` @@ -676,10 +694,92 @@ export const getConfigsAnalysisAnalyzers = async () => { return res.data ?? []; }; +export type ConfigAnalysisStatusItem = { + status: string; +}; + +export const getConfigInsightsStatuses = async () => { + const res = await resolvePostGrestRequestWithPagination< + ConfigAnalysisStatusItem[] | null + >( + ConfigDB.get( + `/config_analysis_items?select=status&status=not.is.null&order=status.asc` + ) + ); + + const uniqueStatuses = Array.from( + new Set( + (res.data ?? []) + .map((item) => item.status) + .filter((item): item is string => !!item) + ) + ); + + return uniqueStatuses.map((status) => ({ status })); +}; + +export type ConfigAnalysisSourceItem = { + source: string; +}; + +export const getConfigInsightsSources = async () => { + const res = await resolvePostGrestRequestWithPagination< + ConfigAnalysisSourceItem[] | null + >( + ConfigDB.get( + `/config_analysis_items?select=source&source=not.is.null&order=source.asc` + ) + ); + + const uniqueSources = Array.from( + new Set( + (res.data ?? []) + .map((item) => item.source) + .filter((item): item is string => !!item) + ) + ); + + return uniqueSources.map((source) => ({ source })); +}; + export type ConfigChangesTypeItem = { change_type: string; }; +export type ConfigInsightsCatalogItem = Pick< + ConfigItem, + "id" | "name" | "config_class" | "type" +>; + +/** + * Returns the distinct set of configs that have at least one config_analysis + * entry, joining config metadata (id, name, config_class, type) from the + * configs table. Deduplication by config id is done client-side. + */ +export const getConfigInsightsCatalogs = async (): Promise< + ConfigInsightsCatalogItem[] +> => { + const res = await resolvePostGrestRequestWithPagination< + { config: ConfigInsightsCatalogItem | null }[] | null + >( + ConfigDB.get( + `/config_analysis_items?select=config:configs(id,name,config_class,type)&config_id=not.is.null`, + { headers: { Prefer: "count=none" } } + ) + ); + + const seen = new Set(); + return (res.data ?? []) + .map((row) => row.config) + .filter((c): c is ConfigInsightsCatalogItem => !!c?.id) + .filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) + .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")); +}; + export const getConfigsChangesTypesFilter = async () => { const res = await IncidentCommander.get( `/change_types?change_type=not.eq.` @@ -713,7 +813,7 @@ export const getConfigInsights = ( | "first_observed" >[] >( - `/config_analysis?select=id,analyzer,analysis_type,message,severity,analysis,first_observed,config:configs(id,name,config_class,type)&config_id=eq.${configId}${paginationQueryParams}`, + `/config_analysis?select=id,analyzer,analysis_type,message,severity,analysis,properties,first_observed,config:configs(id,name,config_class,type)&config_id=eq.${configId}${paginationQueryParams}`, { headers: { Prefer: "count=exact" @@ -725,7 +825,7 @@ export const getConfigInsights = ( export const getConfigInsightsByID = async (id: string) => { const res = await ConfigDB.get( - `/config_analysis?select=id,source,analyzer,analysis_type,message,severity,status,analysis,first_observed,config:configs(id,name,config_class,type)&id=eq.${id}`, + `/config_analysis?select=id,source,analyzer,analysis_type,summary,message,severity,status,analysis,properties,first_observed,last_observed,config:configs(id,name,config_class,type)&id=eq.${id}`, { headers: { Prefer: "count=exact" @@ -798,40 +898,85 @@ export const getAllConfigInsights = async ( type?: string; severity?: string; analyzer?: string; - component?: string; + source?: string; configId?: string; configType?: string; + catalogId?: string; }, sortBy: { sortBy?: string; sortOrder?: "asc" | "desc" }, { pageIndex, pageSize }: PaginationInfo ) => { const pagingParams = `&limit=${pageSize}&offset=${pageIndex * pageSize}`; - const { status, type, severity, analyzer, component, configId, configType } = - queryParams; + const { + status, + type, + severity, + analyzer, + source, + configId, + configType, + catalogId + } = queryParams; + + const toFilterParam = (value: string | undefined, key: string) => { + if (!value) { + return undefined; + } + + if (/(^|,).+:(-1|1)(,|$)/.test(value)) { + return tristateOutputToQueryFilterParam(value, key); + } + + return `&${key}=eq.${value}`; + }; const params = { - status: status && `&status=eq.${status}`, - type: type && `&analysis_type=eq.${type}`, - severity: severity && `&severity=eq.${severity}`, - analyzer: analyzer && `&analyzer=eq.${analyzer}`, - component: component && `&component_id=eq.${component}`, + status: toFilterParam(status, "status"), + type: toFilterParam(type, "analysis_type"), + severity: toFilterParam(severity, "severity"), + analyzer: toFilterParam(analyzer, "analyzer"), + source: toFilterParam(source, "source"), configId: configId && `&config_id=eq.${configId}`, - configType: configType && `&config_type=eq.${configType}` + configType: toFilterParam(configType, "config_type") + ?.split(",") + .map((v) => v.replaceAll("__", "::")) + .join(","), + // Prefer explicit configId when both are present. + catalogId: !configId ? toFilterParam(catalogId, "config_id") : undefined }; const queryParamsString = Object.values(params) .filter((value) => !!value) .join(""); - const sortString = sortBy.sortBy - ? `&order=${sortBy.sortBy}.${sortBy.sortOrder}` + // Map allowed UI sort column IDs to DB field names. + const sortColumnMap: Record = { + // Sort catalog by config name exposed by config_analysis_items view + catalog: "config_name", + analysis_type: "analysis_type", + analyzer: "analyzer", + summary: "summary", + severity: "severity", + status: "status", + source: "source", + first_observed: "first_observed", + last_observed: "last_observed" + }; + + const resolvedSortBy = sortBy.sortBy + ? sortColumnMap[sortBy.sortBy] + : undefined; + + const resolvedSortOrder = sortBy.sortOrder ?? "desc"; + const sortString = resolvedSortBy + ? `&order=${resolvedSortBy}.${resolvedSortOrder}` : // default sort by first_observed "&order=first_observed.desc"; return resolvePostGrestRequestWithPagination( ConfigDB.get( - `/config_analysis_items?select=id,analysis_type,analyzer,severity,status,first_observed,last_observed,config:configs(id,name,config_class,type)${pagingParams}${queryParamsString}${sortString}`, + `/config_analysis_items?select=id,analysis_type,analyzer,summary,severity,status,source,first_observed,last_observed,config_name,config:configs(id,name,config_class,type)${pagingParams}${queryParamsString}${sortString}`, { headers: { Prefer: "count=exact" @@ -862,13 +1007,6 @@ export const getComponentConfigChanges = async (topologyID: string) => { return res.data; }; -export const getConfigAnalysisByComponent = async (componentId: string) => { - const res = await ConfigDB.get( - `/rpc/lookup_analysis_by_component?id=${componentId}` - ); - return res.data; -}; - export type ConfigChildItem = { id: string; type: string; 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/api/services/jobsHistory.ts b/src/api/services/jobsHistory.ts index 5c42bab42c..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; }; @@ -79,3 +135,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/api/services/permissions.ts b/src/api/services/permissions.ts index 39f5dd0808..9e4760b325 100644 --- a/src/api/services/permissions.ts +++ b/src/api/services/permissions.ts @@ -2,6 +2,7 @@ import { IncidentCommander } from "../axios"; import { resolvePostGrestRequestWithPagination } from "../resolve"; import { PermissionsSummary, PermissionTable } from "../types/permissions"; import { AVATAR_INFO } from "@flanksource-ui/constants"; +import { tristateOutputToQueryParamValue } from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; export type FetchPermissionsInput = { componentId?: string; @@ -11,9 +12,20 @@ export type FetchPermissionsInput = { checkId?: string; canaryId?: string; playbookId?: string; + playbookName?: string; + playbookNamespace?: string; connectionId?: string; subject?: string; - subject_type?: "playbook" | "team" | "person" | "notification" | "component"; + action?: string; + direction?: "inbound" | "outbound"; + subject_type?: + | "playbook" + | "team" + | "person" + | "notification" + | "component" + | "role" + | "access_token_person"; }; function composeQueryParamForFetchPermissions({ @@ -26,54 +38,84 @@ function composeQueryParamForFetchPermissions({ playbookId, connectionId, subject, + action, subject_type }: FetchPermissionsInput) { + const filters: string[] = []; + if (componentId) { - return `component_id=eq.${componentId}`; + filters.push(`component_id=eq.${componentId}`); } if (personId) { - return `person_id=eq.${personId}`; + filters.push(`person_id=eq.${personId}`); } if (teamId) { - return `team_id=eq.${teamId}`; + filters.push(`team_id=eq.${teamId}`); } if (configId) { - return `config_id=eq.${configId}`; + filters.push(`config_id=eq.${configId}`); } if (checkId) { - return `check_id=eq.${checkId}`; + filters.push(`check_id=eq.${checkId}`); } if (canaryId) { - return `canary_id=eq.${canaryId}`; + filters.push(`canary_id=eq.${canaryId}`); } if (playbookId) { - return `playbook_id=eq.${playbookId}`; + filters.push(`playbook_id=eq.${playbookId}`); } if (connectionId) { - return `connection_id=eq.${connectionId}`; + filters.push(`connection_id=eq.${connectionId}`); } if (subject) { - return `subject=eq.${subject}`; + filters.push(`subject=eq.${subject}`); + } + if (action) { + const actionFilter = tristateOutputToQueryParamValue(action, true); + if (actionFilter && action.includes(":")) { + filters.push(`action.filter=${actionFilter}`); + } else { + filters.push(`action=eq.${action}`); + } } if (subject_type) { - return `subject_type=eq.${subject_type}`; + filters.push(`subject_type=eq.${subject_type}`); } - return ""; + + return filters.join("&"); } export function fetchPermissions( input: FetchPermissionsInput, - pagination: { + options: { pageSize: number; pageIndex: number; + sortBy?: string; + sortOrder?: "asc" | "desc"; } ) { const queryParam = composeQueryParamForFetchPermissions(input); const selectFields = `*,created_by(${AVATAR_INFO})`; - const { pageSize, pageIndex } = pagination; + const { pageSize, pageIndex, sortBy, sortOrder } = options; + const sortParams = sortBy ? `&order=${sortBy}.${sortOrder ?? "asc"}` : ""; + + 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}`; - const url = `/permissions_summary?${queryParam}&select=${selectFields}&deleted_at=is.null&limit=${pageSize}&offset=${pageIndex * pageSize}`; return resolvePostGrestRequestWithPagination( IncidentCommander.get(url, { headers: { @@ -114,3 +156,102 @@ export function recheckPermission(id: string) { error: null }); } + +// 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.${INTERACTIVE_SETTINGS_PERMISSION_SOURCE}&deleted_at=is.null&limit=5000` + ); + + return response.data ?? []; +} + +export async function fetchMcpUserPermissions() { + const response = await IncidentCommander.get( + `/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 ?? []; +} + +export type PermissionSubject = { + id: string; + name: string; + type: + | "team" + | "permission_subject_group" + | "person" + | "role" + | "access_token_person"; + owner?: string | null; +}; + +export async function fetchPermissionSubjectsPaginated({ + search = "", + pageIndex = 0, + pageSize = 20 +}: { + search?: string; + pageIndex?: number; + pageSize?: number; +}) { + const query = search.trim(); + + let url = "/permission_subjects?select=id,name,type,owner&order=name.asc"; + url += `&limit=${pageSize}&offset=${pageIndex * pageSize}`; + + if (query) { + url += `&name=ilike.*${encodeURIComponent(query)}*`; + } + + return resolvePostGrestRequestWithPagination( + IncidentCommander.get(url, { + headers: { + Prefer: "count=exact" + } + }) + ); +} + +export async function fetchPermissionSubjectsByIds(ids: string[]) { + if (ids.length === 0) { + return []; + } + const response = await IncidentCommander.get( + `/permission_subjects?select=id,name,type,owner&id=in.(${ids.join(",")})&limit=${ids.length}` + ); + return response.data ?? []; +} + +async function fetchPermissionSubjectsWithOrder(order: string) { + const response = await IncidentCommander.get( + `/permission_subjects?select=id,name,type,owner&order=${order}&limit=5000` + ); + + return response.data ?? []; +} + +export async function fetchPermissionSubjects() { + return fetchPermissionSubjectsWithOrder("name.asc"); +} + +export async function fetchAllPermissionSubjects() { + return fetchPermissionSubjectsWithOrder("type.asc,name.asc"); +} diff --git a/src/api/services/playbooks.ts b/src/api/services/playbooks.ts index c9a0be84ca..468d5e762f 100644 --- a/src/api/services/playbooks.ts +++ b/src/api/services/playbooks.ts @@ -27,7 +27,7 @@ export async function getAllPlaybooksSpecs() { export async function getAllPlaybookNames() { const res = await IncidentCommander.get( - `/playbook_names?select=id,name,title,icon,category&order=title.asc` + `/playbook_names?select=id,name,namespace,title,icon,category,description&order=title.asc` ); return res.data ?? []; } diff --git a/src/api/services/rbac.ts b/src/api/services/rbac.ts index aa441a09d2..dc7723d968 100644 --- a/src/api/services/rbac.ts +++ b/src/api/services/rbac.ts @@ -54,3 +54,195 @@ export async function getPermissions(id: string): Promise { ); return response.data.payload ?? []; } + +export type SubjectAccessReviewResource = { + playbook?: string; + view?: string; + connection?: string; + config?: string; + check?: string; + global?: string; + [key: string]: string | undefined; +}; + +export type SubjectAccessReviewAction = + | "read" + | "update" + | "delete" + | "mcp:run" + | "mcp:use" + | "playbook:run" + | "playbook:cancel" + | "playbook:approve"; + +export type SubjectAccessReviewRequest = { + resource: SubjectAccessReviewResource; + action: SubjectAccessReviewAction; + subjects: string[]; +}; + +export type SubjectAccessReviewMatchedPolicy = { + subject: string; + object: string; + action: string; + effect: "allow" | "deny"; + condition?: string; + id?: string; +}; + +export type SubjectAccessReviewResult = { + subject: string; + allowed: boolean; + reason?: string; + trace?: { + allow_count: number; + deny_count: number; + matched_policies: SubjectAccessReviewMatchedPolicy[]; + }; + error?: string; +}; + +export type SubjectAccessReviewResponse = { + resource: SubjectAccessReviewResource; + action: SubjectAccessReviewAction; + results: SubjectAccessReviewResult[]; +}; + +export async function reviewSubjectAccess( + payload: SubjectAccessReviewRequest +): Promise { + const response = await Rback.post( + "/subject-access-reviews", + payload + ); + + return response.data; +} + +export type EffectiveSubjectResourceAccessResource = { + id: string; + type: "playbook" | "view" | "connection"; +}; + +export type EffectiveSubjectResourceAccessRequest = { + subject: string; + action: SubjectAccessReviewAction; + resources: EffectiveSubjectResourceAccessResource[]; +}; + +export type EffectiveSubjectResourceAccessResult = { + resourceId: string; + resourceType: "playbook" | "view" | "connection"; + allowed: boolean; +}; + +export type EffectiveSubjectResourceAccessResponse = { + subject: string; + action: SubjectAccessReviewAction; + results: EffectiveSubjectResourceAccessResult[]; +}; + +type SubjectAccessSearchRequest = { + subject: string; + action: SubjectAccessReviewAction; + resource_types?: Array<"playbook" | "view" | "connection">; + search?: string; + namespace?: string; +}; + +type SubjectAccessSearchResponse = { + subject: string; + action: SubjectAccessReviewAction; + resource_types: Array<"playbook" | "view" | "connection">; + total: number; + limit: number; + offset: number; + results: Array<{ + resource_type: "playbook" | "view" | "connection"; + id: string; + name: string; + namespace?: string; + }>; +}; + +export async function fetchEffectiveSubjectResourceAccess( + payload: EffectiveSubjectResourceAccessRequest +): Promise { + const resourceTypes = Array.from( + new Set(payload.resources.map((resource) => resource.type)) + ); + + const allowedResourceKeys = new Set(); + + const response = await Rback.post( + "/subject-access-search", + { + subject: payload.subject, + action: payload.action, + resource_types: resourceTypes + } satisfies SubjectAccessSearchRequest + ); + + const data = response.data; + + for (const result of data.results ?? []) { + allowedResourceKeys.add(`${result.resource_type}:${result.id}`); + } + + return { + subject: payload.subject, + action: payload.action, + results: payload.resources.map((resource) => ({ + resourceId: resource.id, + resourceType: resource.type, + allowed: allowedResourceKeys.has(`${resource.type}:${resource.id}`) + })) + }; +} + +export type EffectiveResourceSubjectAccessRequest = { + resource: EffectiveSubjectResourceAccessResource; + action: SubjectAccessReviewAction; + subjects: string[]; +}; + +export type EffectiveResourceSubjectAccessResult = { + subjectId: string; + allowed: boolean; +}; + +export type EffectiveResourceSubjectAccessResponse = { + resource: EffectiveSubjectResourceAccessResource; + action: SubjectAccessReviewAction; + results: EffectiveResourceSubjectAccessResult[]; +}; + +export async function fetchEffectiveResourceSubjectAccess( + payload: EffectiveResourceSubjectAccessRequest +): Promise { + const resource: SubjectAccessReviewResource = + payload.resource.type === "playbook" + ? { playbook: payload.resource.id } + : payload.resource.type === "view" + ? { view: payload.resource.id } + : { connection: payload.resource.id }; + + const response = await reviewSubjectAccess({ + resource, + action: payload.action, + subjects: ["*"] + }); + + const allowedBySubject = new Map( + (response.results ?? []).map((result) => [result.subject, result.allowed]) + ); + + return { + resource: payload.resource, + action: payload.action, + results: payload.subjects.map((subjectId) => ({ + subjectId, + allowed: allowedBySubject.get(subjectId) === true + })) + }; +} 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/api/types/configs.ts b/src/api/types/configs.ts index 8c272d7732..eeb3215adb 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -1,5 +1,6 @@ import { Agent, Avatar, CreatedAt, Timestamped } from "../traits"; import { HealthCheckSummary } from "./health"; +import { Property } from "./topology"; export interface ConfigChange extends CreatedAt { id: string; @@ -22,6 +23,7 @@ export interface ConfigChange extends CreatedAt { tags?: Record; first_observed?: string; count?: number; + inserted_at?: string; } export interface Change { @@ -98,6 +100,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 +114,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; @@ -144,11 +174,13 @@ export interface ConfigAnalysis extends Analysis, CreatedAt, Avatar { summary: string; status: string; message: string; + properties?: Property[] | null; + analysis?: Record | string | null; sanitizedMessageHTML?: string; sanitizedMessageTxt?: string; first_observed: string; last_observed: string; - source: any; + source?: string | null; config?: ConfigItem; } diff --git a/src/api/types/notifications.ts b/src/api/types/notifications.ts index 3a80694886..ba29caef66 100644 --- a/src/api/types/notifications.ts +++ b/src/api/types/notifications.ts @@ -161,6 +161,7 @@ export type NotificationSendHistorySummary = { sent: number; error: number; suppressed: number; + in_progress?: number; }; export type NotificationSendHistoryApiResponse = NotificationSendHistory & { diff --git a/src/api/types/permissions.ts b/src/api/types/permissions.ts index a7c4739680..2a219ded58 100644 --- a/src/api/types/permissions.ts +++ b/src/api/types/permissions.ts @@ -11,8 +11,15 @@ export type PermissionGlobalObject = | "canaries" | "connection" | "playbook" - | "topology"; + | "topology" + | "mcp"; +/** + * Selector map for `object_selector` in a permission rule. + * + * Each key narrows the permission scope for that resource type. + * If omitted, the permission applies to all objects of the selected global object. + */ type PermissionObjectSelector = { playbooks?: Selectors[]; connections?: Selectors[]; @@ -22,7 +29,11 @@ type PermissionObjectSelector = { views?: ViewSelector[]; }; -interface Selectors {} +interface Selectors { + id?: string; + name?: string; + namespace?: string; +} interface ScopeSelector { namespace?: string; @@ -46,7 +57,9 @@ export type PermissionTable = { | "team" | "person" | "notification" - | "component"; + | "component" + | "role" + | "access_token_person"; created_by: string; updated_by: string; created_at: string; diff --git a/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx b/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx index ade7622722..b9d1512b5a 100644 --- a/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx +++ b/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx @@ -8,7 +8,11 @@ export function useAgentsBaseURL() { // if we are on the SaaS platform, we need to use the backend URL from the user // profile, not the current URL const baseUrl = - authSystem === "clerk" ? backendUrl : window.location.origin + "/api"; + authSystem === "clerk" + ? backendUrl + : typeof window !== "undefined" + ? window.location.origin + "/api" + : ""; return baseUrl; } diff --git a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx index 8212540e2c..1716b693a4 100644 --- a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx +++ b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx @@ -8,6 +8,7 @@ import { import { Fragment } from "react"; import { FaUserAlt } from "react-icons/fa"; import { useUser } from "../../../context"; +import { hasImpersonatedScopes } from "../../../components/Scopes/Impersonation/scopeImpersonationStore"; import { ClickableSvg } from "../../../ui/ClickableSvg/ClickableSvg"; import { VersionInfo } from "../../VersionInfo/VersionInfo"; import KratosLogoutButton from "./KratosLogoutButton"; @@ -16,12 +17,16 @@ type UserProfileDropdownProps = { openKubeConfigModal: () => void; openMcpTokenModal: () => void; openResourceSelectorSearchModal: () => void; + openScopeImpersonationModal: () => void; + showScopeImpersonation?: boolean; }; export function KratosUserProfileDropdown({ openKubeConfigModal, openMcpTokenModal, - openResourceSelectorSearchModal + openResourceSelectorSearchModal, + openScopeImpersonationModal, + showScopeImpersonation = false }: UserProfileDropdownProps) { const { user } = useUser(); const userNavigation = [{ name: "Your Profile", href: "/profile-settings" }]; @@ -95,6 +100,19 @@ export function KratosUserProfileDropdown({ Resource selector search + {showScopeImpersonation && ( + + + + )} diff --git a/src/components/Authentication/Kratos/ory/errors.tsx b/src/components/Authentication/Kratos/ory/errors.tsx index 5120d9601e..bb0c7d89d8 100644 --- a/src/components/Authentication/Kratos/ory/errors.tsx +++ b/src/components/Authentication/Kratos/ory/errors.tsx @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import { NextRouter } from "next/router"; import { Dispatch, SetStateAction } from "react"; import toast from "react-hot-toast"; +import { sanitizeReturnTo } from "./returnTo"; // A small function to help us deal with errors coming from fetching a flow. export function handleGetFlowError( @@ -16,10 +17,14 @@ export function handleGetFlowError( // 2FA is enabled and enforced, but user did not perform 2fa yet! window.location.href = err.response?.data.redirect_browser_to; return; - case "session_already_available": - // User is already signed in, let's redirect them home! - await router.push("/"); + case "session_already_available": { + // User is already signed in; continue the requested flow when return_to is present. + const returnTo = new URLSearchParams(window.location.search).get( + "return_to" + ); + await router.push(sanitizeReturnTo(returnTo)); return; + } case "session_refresh_required": // We need to re-authenticate to perform this action window.location.href = `/login?return_to=${url}&refresh=true`; diff --git a/src/components/Authentication/Kratos/ory/hooks.ts b/src/components/Authentication/Kratos/ory/hooks.ts index a3cdcb8c13..695657aa85 100644 --- a/src/components/Authentication/Kratos/ory/hooks.ts +++ b/src/components/Authentication/Kratos/ory/hooks.ts @@ -8,6 +8,7 @@ import { useState } from "react"; import toast from "react-hot-toast"; +import { sanitizeReturnTo } from "./returnTo"; import ory from "./sdk"; export function handleGetFlowError( @@ -32,10 +33,14 @@ export function handleGetFlowError( } await router.push("/login?aal=aal2&return_to=" + window.location.href); return; - case "session_already_available": - // User is already signed in, let's redirect them home! - await router.push("/"); + case "session_already_available": { + // User is already signed in; continue the requested flow when return_to is present. + const returnTo = new URLSearchParams(window.location.search).get( + "return_to" + ); + await router.push(sanitizeReturnTo(returnTo)); return; + } case "session_refresh_required": // We need to re-authenticate to perform this action window.location.href = err.response?.data.redirect_browser_to; diff --git a/src/components/Authentication/Kratos/ory/returnTo.ts b/src/components/Authentication/Kratos/ory/returnTo.ts new file mode 100644 index 0000000000..a80ac82ced --- /dev/null +++ b/src/components/Authentication/Kratos/ory/returnTo.ts @@ -0,0 +1,39 @@ +const DEFAULT_RETURN_TO = "/"; + +/** + * Only allow app-internal redirects. + * Reject backslash tricks, protocol-relative URLs and cross-origin targets. + */ +export function sanitizeReturnTo( + returnTo: string | null | undefined, + fallback: string = DEFAULT_RETURN_TO +): string { + if (!returnTo) { + return fallback; + } + + const normalizedReturnTo = returnTo.trim().replace(/\\+/g, "/"); + if ( + returnTo.includes("\\") || + normalizedReturnTo.startsWith("//") || + !normalizedReturnTo.startsWith("/") + ) { + return fallback; + } + + try { + const currentOrigin = + typeof window !== "undefined" + ? window.location.origin + : "https://app.local"; + + const parsed = new URL(normalizedReturnTo, currentOrigin); + if (parsed.origin !== currentOrigin) { + return fallback; + } + + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return fallback; + } +} diff --git a/src/components/Authentication/Kratos/ory/ui/Flow.tsx b/src/components/Authentication/Kratos/ory/ui/Flow.tsx index 2a9dd04f5d..5d639d0de0 100644 --- a/src/components/Authentication/Kratos/ory/ui/Flow.tsx +++ b/src/components/Authentication/Kratos/ory/ui/Flow.tsx @@ -85,20 +85,22 @@ export class Flow extends Component, State> { } initializeValues = (nodes: Array = []) => { - // Compute the values const values = emptyState(); nodes.forEach((node) => { // This only makes sense for text nodes if (isUiNodeInputAttributes(node.attributes)) { - if ( - node.attributes.type === "button" || - node.attributes.type === "submit" - ) { - // In order to mimic real HTML forms, we need to skip setting the value - // for buttons as the button value will (in normal HTML forms) only trigger - // if the user clicks it. + const nodeType = node.attributes.type; + const isSubmitNode = nodeType === "button" || nodeType === "submit"; + if (isSubmitNode && node.group === "oidc") { + // Skip OIDC buttons — the user must click one explicitly so we know + // which provider they chose. + // + // Non-oidc submit buttons (e.g. method=password) + // are pre-populated so they're included even when the form is submitted + // programmatically (e.g. 1Password auto-fill bypasses the onClick handler). return; } + values[node.attributes.name as keyof Values] = node.attributes.value; } }); 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/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/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/components/Configs/Insights/ConfigAnalysisLink/ConfigInsightsDetailsModal.tsx b/src/components/Configs/Insights/ConfigAnalysisLink/ConfigInsightsDetailsModal.tsx index ef04a141c1..e80a6d1f91 100644 --- a/src/components/Configs/Insights/ConfigAnalysisLink/ConfigInsightsDetailsModal.tsx +++ b/src/components/Configs/Insights/ConfigAnalysisLink/ConfigInsightsDetailsModal.tsx @@ -1,17 +1,106 @@ import { useQuery } from "@tanstack/react-query"; import { sanitize } from "dompurify"; -import { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { + FiCheckCircle, + FiCircle, + FiExternalLink, + FiXCircle +} from "react-icons/fi"; import { getConfigInsightsByID } from "../../../../api/services/configs"; import { EvidenceType } from "../../../../api/types/evidence"; +import { Property } from "../../../../api/types/topology"; +import { Badge, badgeVariants } from "../../../ui/badge"; +import { cn } from "../../../../lib/utils"; +import { JSONViewer } from "../../../../ui/Code/JSONViewer"; import { Modal } from "../../../../ui/Modal"; -import ModalTitleListItems from "../../../../ui/Modal/ModalTitleListItems"; -import TextSkeletonLoader from "../../../../ui/SkeletonLoader/TextSkeletonLoader"; -import { formatISODate, isValidDate } from "../../../../utils/date"; +import Age from "../../../../ui/Age/Age"; +import { Tab, Tabs } from "../../../../ui/Tabs/Tabs"; import { DescriptionCard } from "../../../DescriptionCard"; import AttachAsEvidenceButton from "../../../Incidents/AttachEvidenceDialog/AttachAsEvidenceDialogButton"; import ConfigLink from "../../ConfigLink/ConfigLink"; import ConfigInsightsIcon from "../ConfigInsightsIcon"; +const statusConfig: Record = { + open: { icon: }, + resolved: { icon: }, + closed: { icon: } +}; + +const severityBadgeClass: Record = { + info: "bg-gray-100 border-gray-300 text-gray-700", + low: "bg-green-50 border-green-300 text-green-700", + medium: "bg-yellow-50 border-yellow-300 text-yellow-700", + warning: "bg-yellow-100 border-yellow-400 text-yellow-800", + high: "bg-orange-50 border-orange-300 text-orange-700", + blocker: "bg-red-50 border-red-300 text-red-700", + critical: "bg-red-100 border-red-400 text-red-800" +}; + +/** A single badge-type property rendered as an inline pill. */ +function AnalysisBadge({ property }: { property: Property }) { + const primaryLink = property.links?.[0]?.url; + + let displayText: string | undefined; + if (property.value != null && property.max != null) { + // Normalise both CVSS (75/100) and OpenSSF (7/10) to a /10 display. + const scaled = (Number(property.value) / Number(property.max)) * 10; + const formatted = Number.isInteger(scaled) + ? String(scaled) + : scaled.toFixed(1); + displayText = `${formatted}/10`; + } else if (property.value != null) { + displayText = String(property.value); + } else { + displayText = property.text; + } + + if (!displayText) return null; + + const colorClass = + property.color ?? "bg-gray-100 border-gray-200 text-gray-700"; + + const pill = ( + + {property.name}: + {displayText} + {primaryLink && } + + ); + + return primaryLink ? ( + + {property.name}: + {displayText} + + + ) : ( + pill + ); +} + +/** All badge-type properties rendered as a flex-wrap row of pills. */ +function AnalysisBadges({ properties }: { properties: Property[] }) { + const badges = properties.filter((p) => p.type === "badge"); + if (badges.length === 0) return null; + return ( +
+ {badges.map((property, idx) => ( + + ))} +
+ ); +} + type Props = { id?: string; isOpen: boolean; @@ -23,104 +112,261 @@ export default function ConfigInsightsDetailsModal({ isOpen, onClose }: Props) { + const [activeTab, setActiveTab] = useState<"message" | "analysis">("message"); + + useEffect(() => { + if (isOpen) { + setActiveTab("message"); + } + }, [isOpen]); + const { data: configInsight, isLoading } = useQuery( ["config", "insights", id], () => getConfigInsightsByID(id!), { - enabled: isOpen && !!id + enabled: isOpen && !!id, + onError: () => onClose() } ); - const properties = useMemo(() => { + const descriptionItems = useMemo(() => { + if (!configInsight) { + return []; + } + // Find the URL property to link the source field. + const urlProp = configInsight?.properties?.find((p) => p.type === "url"); + const sourceHref = urlProp?.links?.[0]?.url ?? urlProp?.text; + return [ { label: "Type", value: (
- - {configInsight?.analysis_type} + + {configInsight.analysis_type + ? configInsight.analysis_type.charAt(0).toUpperCase() + + configInsight.analysis_type.slice(1) + : ""}
) }, { label: "First Observed", - value: isValidDate(configInsight?.first_observed) - ? formatISODate(configInsight?.first_observed) - : "" + value: }, { label: "Last Observed", - value: isValidDate(configInsight?.last_observed) - ? formatISODate(configInsight?.last_observed) - : "" + value: }, { label: "Severity", - value: configInsight?.severity! || "" + value: configInsight?.severity ? ( + + {configInsight.severity.charAt(0).toUpperCase() + + configInsight.severity.slice(1)} + + ) : ( + "" + ) + }, + ...(configInsight?.config != null + ? [ + { + label: "Config", + value: ( + + ) + } + ] + : []), + { + label: "Status", + value: configInsight?.status + ? (() => { + const cfg = statusConfig[configInsight.status]; + return ( +
+ {cfg?.icon} + + {configInsight.status.charAt(0).toUpperCase() + + configInsight.status.slice(1)} + +
+ ); + })() + : "" }, { label: "Source", - value: configInsight?.source! || "" + value: sourceHref ? ( + + {configInsight?.source} + + + ) : ( + (configInsight?.source ?? "") + ) } ]; }, [configInsight]); const sanitizedMessageHTML = useMemo(() => { - return sanitize(configInsight?.message! || ""); + return sanitize(configInsight?.message ?? ""); }, [configInsight]); - if (!configInsight) { + // Render the analysis object as YAML if it exists and is non-empty + const analysisDetails = useMemo(() => { + const analysis = configInsight?.analysis; + if (!analysis) { + return null; + } + if (typeof analysis === "string") { + try { + const parsed = JSON.parse(analysis); + if ( + !parsed || + (typeof parsed === "object" && Object.keys(parsed).length === 0) + ) { + return null; + } + return JSON.stringify(parsed); + } catch { + return null; + } + } + if (typeof analysis === "object" && Object.keys(analysis).length === 0) { + return null; + } + return JSON.stringify(analysis); + }, [configInsight?.analysis]); + + // If there's no message but there is analysis, default to the analysis tab + const resolvedActiveTab = useMemo(() => { + if (activeTab === "message" && !sanitizedMessageHTML && analysisDetails) { + return "analysis" as const; + } + return activeTab; + }, [activeTab, sanitizedMessageHTML, analysisDetails]); + + if (!isOpen || !id) { return null; } return ( + isLoading ? ( +
+ ) : ( +
+ {configInsight ? ( - {configInsight.analyzer} -
, - configInsight.config != null ? ( - - ) : null - ]} - /> + ) : null} + {configInsight?.analyzer} +
+ ) } open={isOpen} - onClose={() => { - onClose(); - }} + onClose={onClose} > {isLoading ? ( - - ) : ( +
+ {/* properties grid */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ {/* summary */} +
+ {/* tabs */} +
+
+
+
+ {/* content */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ) : !configInsight ? null : ( <>
-
- ) - } - ]} + items={descriptionItems} labelStyle="top" + columns={4} /> + {configInsight.properties && + configInsight.properties.length > 0 && ( + + )} + {configInsight.summary && ( +

+ {configInsight.summary.charAt(0).toUpperCase() + + configInsight.summary.slice(1)} +

+ )} + {(sanitizedMessageHTML || analysisDetails) && ( +
+ + {[ + ...(sanitizedMessageHTML + ? [ + +
+ + ] + : []), + ...(analysisDetails + ? [ + + + + ] + : []) + ]} + +
+ )}
diff --git a/src/components/Configs/Insights/ConfigInsightsColumns.tsx b/src/components/Configs/Insights/ConfigInsightsColumns.tsx index 6be9017081..9243d9390f 100644 --- a/src/components/Configs/Insights/ConfigInsightsColumns.tsx +++ b/src/components/Configs/Insights/ConfigInsightsColumns.tsx @@ -1,4 +1,5 @@ import { ConfigAnalysis, ConfigItem } from "@flanksource-ui/api/types/configs"; +import FilterByCellValue from "@flanksource-ui/ui/DataTable/FilterByCellValue"; import { MRTDateCell } from "@flanksource-ui/ui/MRTDataTable/Cells/MRTDateCells"; import { MRT_ColumnDef } from "mantine-react-table"; import { Link } from "react-router-dom"; @@ -6,24 +7,37 @@ import { ConfigIcon } from "../../../ui/Icons/ConfigIcon"; import ConfigInsightsIcon from "./ConfigInsightsIcon"; import ConfigInsightsSeverityIcons from "./ConfigInsightsSeverityIcons"; +const paramsToReset = ["pageIndex", "pageSize"]; + export const ConfigInsightsColumns: MRT_ColumnDef< ConfigAnalysis & { config?: ConfigItem } >[] = [ { header: "Catalog", id: "catalog", + accessorFn: (row) => row.config?.name ?? "", enableHiding: true, size: 100, Cell: ({ cell }) => { const config = cell.row.original.config; + if (!config?.id) { + return ; + } + return ( -
- - - {config?.name} - -
+ +
+ + + {config.name} + +
+
); } }, @@ -31,20 +45,25 @@ export const ConfigInsightsColumns: MRT_ColumnDef< header: "Type", id: "analysis_type", accessorKey: "analysis_type", - size: 50, + size: 80, Cell: ({ cell }) => { const data = cell.row.original; return ( -
- - - - {data.analysis_type} -
+ +
+ + + + {data.analysis_type} +
+
); - }, - enableSorting: false + } }, { header: "Analyzer", @@ -56,27 +75,52 @@ export const ConfigInsightsColumns: MRT_ColumnDef< const value = cell.getValue(); return ( - - {value} + + + {value} + + + ); + } + }, + { + header: "Summary", + id: "summary", + accessorKey: "summary", + size: 300, + Cell: ({ cell }) => { + const value = cell.getValue(); + if (!value) return null; + return ( + + {value.charAt(0).toUpperCase() + value.slice(1)} ); - }, - enableSorting: false + } }, { header: "Severity", id: "severity", accessorKey: "severity", size: 50, - enableSorting: false, Cell: ({ cell }) => { const value = cell.getValue(); return ( -
- - {value} -
+ +
+ + {value} +
+
); } }, @@ -85,7 +129,46 @@ export const ConfigInsightsColumns: MRT_ColumnDef< id: "status", accessorKey: "status", size: 50, - enableSorting: false + Cell: ({ cell }) => { + const value = cell.getValue(); + + if (!value) { + return ; + } + + return ( + + {value} + + ); + } + }, + { + header: "Source", + id: "source", + accessorKey: "source", + size: 80, + Cell: ({ cell }) => { + const value = cell.getValue() ?? ""; + + if (!value) { + return ; + } + + return ( + + {value} + + ); + } }, { header: "First Observed", diff --git a/src/components/Configs/Insights/ConfigInsightsIcon.tsx b/src/components/Configs/Insights/ConfigInsightsIcon.tsx index 112c68e9c6..8e7297e870 100644 --- a/src/components/Configs/Insights/ConfigInsightsIcon.tsx +++ b/src/components/Configs/Insights/ConfigInsightsIcon.tsx @@ -1,5 +1,4 @@ import clsx from "clsx"; -import { useMemo } from "react"; import { CiBandage, CiClock2, @@ -21,16 +20,6 @@ type Props = { size?: number; }; -function insightSeverityToColorMap(severity: string) { - if (severity === "critical") { - return "text-red-500"; - } - if (severity === "warning") { - return "text-yellow-500"; - } - return "text-gray-600"; -} - type InsightTypeToIconProps = { type: string; size?: number; @@ -127,15 +116,5 @@ export function InsightTypeToIcon({ * */ export default function ConfigInsightsIcon({ analysis, size = 22 }: Props) { - const colorClass = useMemo(() => { - return insightSeverityToColorMap(analysis.severity); - }, [analysis.severity]); - - return ( - - ); + return ; } diff --git a/src/components/Configs/Insights/ConfigInsightsList.tsx b/src/components/Configs/Insights/ConfigInsightsList.tsx index c22db0af51..ab4803ec24 100644 --- a/src/components/Configs/Insights/ConfigInsightsList.tsx +++ b/src/components/Configs/Insights/ConfigInsightsList.tsx @@ -1,7 +1,6 @@ import useFetchConfigInsights from "@flanksource-ui/api/query-hooks/useFetchConfigInsights"; -import { ConfigAnalysis } from "@flanksource-ui/api/types/configs"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useSearchParams } from "react-router-dom"; import { InfoMessage } from "../../InfoMessage"; import ConfigInsightsDetailsModal from "./ConfigAnalysisLink/ConfigInsightsDetailsModal"; @@ -14,18 +13,17 @@ type Props = { columnsToHide?: string[]; }; +const INSIGHT_ID_SEARCH_PARAM = "insightId"; + export default function ConfigInsightsList({ setIsLoading, triggerRefresh, configId, columnsToHide = [] }: Props) { - const [params] = useSearchParams(); - const [clickedInsightItem, setClickedInsightItem] = - useState(); - const [isInsightDetailsModalOpen, setIsInsightDetailsModalOpen] = - useState(false); + const [params, setParams] = useSearchParams(); + const selectedInsightId = params.get(INSIGHT_ID_SEARCH_PARAM) ?? undefined; const pageSize = +(params.get("pageSize") ?? 50); const { data, isLoading, refetch, isRefetching, error } = @@ -56,10 +54,14 @@ export default function ConfigInsightsList({ isRefetching={isRefetching} hiddenColumns={columnsToHide} onRowClick={(row) => { - setClickedInsightItem(row); - setIsInsightDetailsModalOpen(true); + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); + nextParams.set(INSIGHT_ID_SEARCH_PARAM, row.id); + return nextParams; + }); }} enableServerSideSorting + enableServerSidePagination totalRowCount={totalEntries} manualPageCount={pageCount} columns={configInsightsColumns} @@ -68,9 +70,15 @@ export default function ConfigInsightsList({ )} setIsInsightDetailsModalOpen(false)} + id={selectedInsightId} + isOpen={!!selectedInsightId} + onClose={() => { + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); + nextParams.delete(INSIGHT_ID_SEARCH_PARAM); + return nextParams; + }); + }} /> ); diff --git a/src/components/Configs/Insights/ConfigInsightsSeverityIcons.tsx b/src/components/Configs/Insights/ConfigInsightsSeverityIcons.tsx index 80f95dae76..e670637fdd 100644 --- a/src/components/Configs/Insights/ConfigInsightsSeverityIcons.tsx +++ b/src/components/Configs/Insights/ConfigInsightsSeverityIcons.tsx @@ -20,18 +20,20 @@ export default function ConfigInsightsSeverityIcons({ switch (severity) { case "info": return ; - case "warning": - return ; case "low": - return ; + return ( + + ); case "medium": - return ; + return ; + case "warning": + return ; case "high": - return ; + return ; case "blocker": - return ; + return ; case "critical": - return ; + return ; default: return null; } diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsAnalyzerDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsAnalyzerDropdown.tsx index 3cf398fe84..188ad7d4d7 100644 --- a/src/components/Configs/Insights/Filters/ConfigInsightsAnalyzerDropdown.tsx +++ b/src/components/Configs/Insights/Filters/ConfigInsightsAnalyzerDropdown.tsx @@ -1,75 +1,47 @@ -import { getConfigsAnalysisAnalyzers } from "@flanksource-ui/api/services/configs"; -import { useQuery } from "@tanstack/react-query"; +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; import { useField } from "formik"; -import React from "react"; -import { defaultSelections } from "../../../Incidents/data"; -import { ReactSelectDropdown, StateOption } from "../../../ReactSelectDropdown"; +import { useMemo } from "react"; -type Props = React.HTMLProps & { - prefix?: string; - dropDownClassNames?: string; - hideControlBorder?: boolean; - showAllOption?: boolean; - paramsToReset?: string[]; +type Props = { + name?: string; + label?: string; + options?: string[]; + isLoading?: boolean; }; export default function ConfigInsightsAnalyzerDropdown({ - prefix = "Analyzer:", name = "analyzer", - className, - showAllOption, - dropDownClassNames, - hideControlBorder, - paramsToReset = [] + label = "Analyzer", + options: rawOptions = [], + isLoading = false }: Props) { - const [field] = useField({ - name - }); + const [field] = useField({ name }); - const { data: analyzers, isLoading } = useQuery( - ["config_analysis_analyzers"], - () => getConfigsAnalysisAnalyzers(), - { - select: (data) => { - return data - .map((item) => { - return { - description: item.analyzer, - value: item.analyzer - }; - }) - .reduce((acc: Record, item) => { - acc[item.value] = item; - return acc; - }, {}); - } - } + const options = useMemo( + () => + rawOptions.map( + (value) => + ({ id: value, label: value, value }) satisfies TriStateOptions + ), + [rawOptions] ); return ( - { if (value && value !== "all") { - field.onChange({ - target: { name, value } - }); + field.onChange({ target: { name, value } }); } else { - field.onChange({ - target: { name, value: undefined } - }); + field.onChange({ target: { name, value: undefined } }); } }} - prefix={{prefix}} - name={name} - className={className} - dropDownClassNames={dropDownClassNames} - value={field.value ?? "all"} - isLoading={isLoading} - items={{ - ...(showAllOption ? defaultSelections : {}), - ...analyzers - }} - hideControlBorder={hideControlBorder} + label={label} /> ); } diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsCatalogDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsCatalogDropdown.tsx new file mode 100644 index 0000000000..ca971b506d --- /dev/null +++ b/src/components/Configs/Insights/Filters/ConfigInsightsCatalogDropdown.tsx @@ -0,0 +1,54 @@ +import { ConfigInsightsCatalogOption } from "@flanksource-ui/api/services/configs"; +import { ConfigIcon } from "@flanksource-ui/ui/Icons/ConfigIcon"; +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useField } from "formik"; +import { useMemo } from "react"; + +type Props = { + name?: string; + label?: string; + options?: ConfigInsightsCatalogOption[]; + isLoading?: boolean; +}; + +export default function ConfigInsightsCatalogDropdown({ + name = "catalogId", + label = "Catalog", + options: rawOptions = [], + isLoading = false +}: Props) { + const [field] = useField({ name }); + + const options = useMemo( + () => + rawOptions.map( + (config) => + ({ + id: config.id, + label: config.name, + value: config.id, + icon: + }) satisfies TriStateOptions + ), + [rawOptions] + ); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name, value } }); + } else { + field.onChange({ target: { name, value: undefined } }); + } + }} + label={label} + /> + ); +} diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsConfigTypesDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsConfigTypesDropdown.tsx new file mode 100644 index 0000000000..f93e1f6838 --- /dev/null +++ b/src/components/Configs/Insights/Filters/ConfigInsightsConfigTypesDropdown.tsx @@ -0,0 +1,60 @@ +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useField } from "formik"; +import { useMemo } from "react"; +import ConfigsTypeIcon from "../../ConfigsTypeIcon"; + +type Props = { + name?: string; + label?: string; + options?: string[]; + isLoading?: boolean; +}; + +export default function ConfigInsightsConfigTypesDropdown({ + name = "configType", + label = "Config Type", + options: rawOptions = [], + isLoading = false +}: Props) { + const [field] = useField({ name }); + + const options = useMemo( + () => + rawOptions.map((type) => { + const typeLabel = + type.split("::").length === 1 + ? type + : type + .substring(type.indexOf("::") + 2) + .replaceAll("::", " ") + .trim(); + + return { + id: type, + label: typeLabel, + value: type, + icon: + } satisfies TriStateOptions; + }), + [rawOptions] + ); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name, value } }); + } else { + field.onChange({ target: { name, value: undefined } }); + } + }} + label={label} + /> + ); +} diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsFilters.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsFilters.tsx index 737b5b98c5..379c7c4541 100644 --- a/src/components/Configs/Insights/Filters/ConfigInsightsFilters.tsx +++ b/src/components/Configs/Insights/Filters/ConfigInsightsFilters.tsx @@ -1,59 +1,79 @@ import FormikFilterForm from "@flanksource-ui/components/Forms/FormikFilterForm"; -import { ComponentNamesDropdown } from "../../../Topology/Dropdowns/ComponentNamesDropdown"; -import { ConfigTypesDropdown } from "../../ConfigsListFilters/ConfigTypesDropdown"; +import { useConfigInsightsFilterOptions } from "@flanksource-ui/api/query-hooks/useConfigInsightsFilterOptions"; import ConfigInsightsAnalyzerDropdown from "./ConfigInsightsAnalyzerDropdown"; +import ConfigInsightsCatalogDropdown from "./ConfigInsightsCatalogDropdown"; +import ConfigInsightsConfigTypesDropdown from "./ConfigInsightsConfigTypesDropdown"; import ConfigInsightsSeverityDropdown from "./ConfigInsightsSeverityDropdown"; +import ConfigInsightsSourceDropdown from "./ConfigInsightsSourceDropdown"; +import ConfigInsightsStatusDropdown from "./ConfigInsightsStatusDropdown"; import ConfigInsightsTypeDropdown from "./ConfigInsightsTypeDropdown"; type ConfigInsightsFiltersProps = { paramsToReset?: string[]; + /** + * When provided (config details page), filter options are scoped to that + * config's analysis rows and the Catalog dropdown is hidden. + */ + configId?: string; }; export function ConfigInsightsFilters({ - paramsToReset = ["pageIndex"] + paramsToReset = ["pageIndex"], + configId }: ConfigInsightsFiltersProps) { + const { data: filterOptions, isLoading } = + useConfigInsightsFilterOptions(configId); + return ( -
-
-
- - - - - -
-
+
+ {/* Catalog filter is only relevant on the global insights page */} + {!configId && ( + + )} + + + + + +
); diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsSeverityDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsSeverityDropdown.tsx index d68957b960..fdc938305a 100644 --- a/src/components/Configs/Insights/Filters/ConfigInsightsSeverityDropdown.tsx +++ b/src/components/Configs/Insights/Filters/ConfigInsightsSeverityDropdown.tsx @@ -1,30 +1,36 @@ +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; import { useField } from "formik"; import React from "react"; -import { defaultSelections, severityItems } from "../../../Incidents/data"; -import { ReactSelectDropdown } from "../../../ReactSelectDropdown"; +import { severityItems } from "../../../Incidents/data"; type Props = React.HTMLProps & { - prefix?: string; - dropDownClassNames?: string; - hideControlBorder?: boolean; - showAllOption?: boolean; + label?: string; }; export default function ConfigInsightsSeverityDropdown({ - prefix = "Severity:", - name = "severity", - className, - showAllOption, - dropDownClassNames, - hideControlBorder + label = "Severity", + name = "severity" }: Props) { const [field] = useField({ name }); + const options = Object.values(severityItems) + .map((item) => ({ + id: item.id, + label: item.name, + value: item.value.toLowerCase(), + icon: item.icon + })) + .sort((a, b) => a.label.localeCompare(b.label)) satisfies TriStateOptions[]; + return ( - {prefix}} + { if (value && value !== "all") { field.onChange({ @@ -36,17 +42,7 @@ export default function ConfigInsightsSeverityDropdown({ }); } }} - name={name} - className={className} - dropDownClassNames={dropDownClassNames} - value={field.value ?? "all"} - items={{ - ...(showAllOption ? defaultSelections : {}), - ...Object.values(severityItems).sort((a, b) => - a.name > b.name ? 1 : -1 - ) - }} - hideControlBorder={hideControlBorder} + label={label} /> ); } diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsSourceDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsSourceDropdown.tsx new file mode 100644 index 0000000000..7491a34630 --- /dev/null +++ b/src/components/Configs/Insights/Filters/ConfigInsightsSourceDropdown.tsx @@ -0,0 +1,47 @@ +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useField } from "formik"; +import { useMemo } from "react"; + +type Props = { + name?: string; + label?: string; + options?: string[]; + isLoading?: boolean; +}; + +export default function ConfigInsightsSourceDropdown({ + name = "source", + label = "Source", + options: rawOptions = [], + isLoading = false +}: Props) { + const [field] = useField({ name }); + + const options = useMemo( + () => + rawOptions.map( + (value) => + ({ id: value, label: value, value }) satisfies TriStateOptions + ), + [rawOptions] + ); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name, value } }); + } else { + field.onChange({ target: { name, value: undefined } }); + } + }} + label={label} + /> + ); +} diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsStatusDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsStatusDropdown.tsx new file mode 100644 index 0000000000..875d34954f --- /dev/null +++ b/src/components/Configs/Insights/Filters/ConfigInsightsStatusDropdown.tsx @@ -0,0 +1,49 @@ +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; +import { useField } from "formik"; +import { useMemo } from "react"; + +// Fixed domain list — these are the only valid analysis statuses. +const STATUS_OPTIONS = ["open", "resolved", "silenced"]; + +type Props = { + name?: string; + label?: string; +}; + +export default function ConfigInsightsStatusDropdown({ + name = "status", + label = "Status" +}: Props) { + const [field] = useField({ name }); + + const options = useMemo( + () => + STATUS_OPTIONS.map( + (value) => + ({ + id: value, + label: value.charAt(0).toUpperCase() + value.slice(1), + value + }) satisfies TriStateOptions + ), + [] + ); + + return ( + { + if (value && value !== "all") { + field.onChange({ target: { name, value } }); + } else { + field.onChange({ target: { name, value: undefined } }); + } + }} + label={label} + /> + ); +} diff --git a/src/components/Configs/Insights/Filters/ConfigInsightsTypeDropdown.tsx b/src/components/Configs/Insights/Filters/ConfigInsightsTypeDropdown.tsx index b8bde3fa9a..c575234bf1 100644 --- a/src/components/Configs/Insights/Filters/ConfigInsightsTypeDropdown.tsx +++ b/src/components/Configs/Insights/Filters/ConfigInsightsTypeDropdown.tsx @@ -1,23 +1,16 @@ +import TristateReactSelect, { + TriStateOptions +} from "@flanksource-ui/ui/Dropdowns/TristateReactSelect"; import { useField } from "formik"; -import React from "react"; -import { defaultSelections } from "../../../Incidents/data"; -import { ReactSelectDropdown } from "../../../ReactSelectDropdown"; +import { useMemo } from "react"; import ConfigInsightsIcon from "../ConfigInsightsIcon"; -export const configAnalysisTypeItems = { - Cost: { - id: "dropdown-type-cost", - icon: ( - - ), - name: "Cost", - description: "Cost", - value: "cost" - }, - Availability: { +// Full set of analysis type definitions with icons, keyed by value. +export const configAnalysisTypeItems: Record< + string, + { id: string; label: string; value: string; icon: React.ReactNode } +> = { + availability: { id: "dropdown-type-availability", icon: ( ), - name: "Availability", - description: "Availability", + label: "Availability", value: "availability" }, - Performance: { - id: "dropdown-type-performance", + compliance: { + id: "dropdown-type-compliance", icon: ( ), - name: "Performance", - description: "Performance", - value: "performance" + label: "Compliance", + value: "compliance" }, - Security: { - id: "dropdown-type-security", + cost: { + id: "dropdown-type-cost", icon: ( ), - name: "Security", - description: "Security", - value: "security" + label: "Cost", + value: "cost" }, integration: { id: "dropdown-type-integration", @@ -61,35 +51,43 @@ export const configAnalysisTypeItems = { size={18} /> ), - name: "Integration", - description: "Integration", + label: "Integration", value: "integration" }, - Compliance: { - id: "dropdown-type-compliance", + other: { + id: "dropdown-type-other", icon: ( ), - name: "Compliance", - description: "Compliance", - value: "compliance" + label: "Other", + value: "other" }, - TechnicalDebt: { - id: "dropdown-type-technical-debt", + performance: { + id: "dropdown-type-performance", icon: ( ), - name: "Technical Debt", - description: "Technical Debt", - value: "technical_debt" + label: "Performance", + value: "performance" + }, + recommendation: { + id: "dropdown-type-recommendation", + icon: ( + + ), + label: "Recommendation", + value: "recommendation" }, - Reliability: { + reliability: { id: "dropdown-type-reliability", icon: ( ), - name: "Reliability", - description: "Reliability", + label: "Reliability", value: "reliability" }, - Recommendation: { - id: "dropdown-type-recommendation", + security: { + id: "dropdown-type-security", icon: ( ), - name: "Recommendation", - description: "Recommendation", - value: "recommendation" + label: "Security", + value: "security" }, - Other: { - id: "dropdown-type-other", + technical_debt: { + id: "dropdown-type-technical-debt", icon: ( ), - name: "Other", - description: "Other", - value: "other" + label: "Technical Debt", + value: "technical_debt" } -} as const; +}; -type Props = React.HTMLProps & { - prefix?: string; - dropDownClassNames?: string; - hideControlBorder?: boolean; - showAllOption?: boolean; +type Props = { + name?: string; + label?: string; + /** Values returned by the SP — only types present in the data are shown. */ + options?: string[]; + isLoading?: boolean; }; export default function ConfigInsightsTypeDropdown({ - prefix = "Type:", name = "type", - className, - showAllOption, - dropDownClassNames, - hideControlBorder + label = "Type", + options: availableTypes = [], + isLoading = false }: Props) { - const [field] = useField({ - name - }); + const [field] = useField({ name }); + + const options = useMemo(() => { + const typeSet = new Set(availableTypes); + return Object.values(configAnalysisTypeItems) + .filter(({ value }) => typeSet.size === 0 || typeSet.has(value)) + .sort((a, b) => a.label.localeCompare(b.label)) + .map(({ id, label, value, icon }) => ({ + id, + label, + value, + icon + })) satisfies TriStateOptions[]; + }, [availableTypes]); return ( - { if (value && value !== "all") { - field.onChange({ - target: { name, value } - }); + field.onChange({ target: { name, value } }); } else { - field.onChange({ - target: { name, value: undefined } - }); + field.onChange({ target: { name, value: undefined } }); } }} - prefix={ -
{prefix}
- } - name={name} - className={className} - dropDownClassNames={dropDownClassNames} - value={field.value ?? "all"} - items={{ - ...(showAllOption ? defaultSelections : {}), - ...Object.values(configAnalysisTypeItems).sort((a, b) => - a.name > b.name ? 1 : -1 - ) - }} - hideControlBorder={hideControlBorder} + label={label} /> ); } diff --git a/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx b/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx index 1291a3b746..5601943f27 100644 --- a/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx +++ b/src/components/Configs/Sidebar/__tests__/ConfigDetails.unit.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { ConfigDetails } from "./../ConfigDetails"; import { Provider as JotaiProvider } from "jotai"; @@ -46,8 +46,7 @@ const renderWithProviders = (component: React.ReactElement) => { const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: false, - gcTime: 0 + retry: false } } }); diff --git a/src/components/Connections/ConnectionForm.tsx b/src/components/Connections/ConnectionForm.tsx index edf9fe4e62..da41ced1cb 100644 --- a/src/components/Connections/ConnectionForm.tsx +++ b/src/components/Connections/ConnectionForm.tsx @@ -132,8 +132,8 @@ export function ConnectionForm({ )} {...props} > -
-
+
+
{isReadOnly && (

diff --git a/src/components/Connections/ConnectionFormModal.tsx b/src/components/Connections/ConnectionFormModal.tsx index 8bc3a8e9d3..46ef7e9867 100644 --- a/src/components/Connections/ConnectionFormModal.tsx +++ b/src/components/Connections/ConnectionFormModal.tsx @@ -178,9 +178,23 @@ export default function ConnectionFormModal({ ]} /> ) : ( - + setConnectionType(undefined)} + connectionType={type} + onConnectionSubmit={onConnectionSubmit} + onConnectionDelete={onConnectionDelete} + formValue={formValue} + className={className} + isSubmitting={isSubmitting} + isDeleting={isDeleting} + /> +

+ ) + ) : formValue?.id ? ( +
+ setConnectionType(undefined)} - connectionType={type} onConnectionSubmit={onConnectionSubmit} onConnectionDelete={onConnectionDelete} formValue={formValue} @@ -188,17 +202,7 @@ export default function ConnectionFormModal({ isSubmitting={isSubmitting} isDeleting={isDeleting} /> - ) - ) : formValue?.id ? ( - setConnectionType(undefined)} - onConnectionSubmit={onConnectionSubmit} - onConnectionDelete={onConnectionDelete} - formValue={formValue} - className={className} - isSubmitting={isSubmitting} - isDeleting={isDeleting} - /> +
) : ( )} 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/Connections/__tests__/ConnectionsList.unit.test.tsx b/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx index e6c1d5a784..7ea6305418 100644 --- a/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx +++ b/src/components/Connections/__tests__/ConnectionsList.unit.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { Connection } from "../ConnectionFormModal"; import { ConnectionList } from "../ConnectionsList"; +import { ConnectionValueType } from "../connectionTypes"; // Mock the CRDSource component jest.mock("../../Settings/CRDSource", () => { @@ -71,16 +72,14 @@ describe("ConnectionsList", () => { const mockConnectionWithCRD: Connection = { id: "conn-123", name: "Test CRD Connection", - type: "postgres", - source: "KubernetesCRD", - created_by: { id: "user-1", name: "User One" } + type: ConnectionValueType.Postgres, + source: "KubernetesCRD" }; const mockConnectionWithUser: Connection = { id: "conn-456", name: "Test User Connection", - type: "mysql", - created_by: { id: "user-2", name: "User Two" } + type: ConnectionValueType.MySQL }; it("should display CRD component when source is KubernetesCRD", () => { 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, 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/components/Forms/Formik/FormikResourceSelectorDropdown.tsx b/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx index 901ef2ab72..557532ad4f 100644 --- a/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx +++ b/src/components/Forms/Formik/FormikResourceSelectorDropdown.tsx @@ -28,6 +28,7 @@ type FormikConfigsDropdownProps = { className?: string; valueField?: "id" | "name"; disabled?: boolean; + renderMenuInPortal?: boolean; }; export default function FormikResourceSelectorDropdown({ @@ -43,7 +44,8 @@ export default function FormikResourceSelectorDropdown({ playbookResourceSelector, className = "flex flex-col space-y-2 py-2", valueField = "id", - disabled = false + disabled = false, + renderMenuInPortal = true }: FormikConfigsDropdownProps) { const [inputText, setInputText] = useState(""); const [searchText, setSearchText] = useState(""); @@ -332,11 +334,15 @@ export default function FormikResourceSelectorDropdown({ }} onInputChange={handleInputChange} inputValue={inputText ?? value} - menuPortalTarget={document.body} + menuPortalTarget={ + renderMenuInPortal && typeof window !== "undefined" + ? document.body + : undefined + } styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }} - menuPosition={"fixed"} + menuPosition={renderMenuInPortal ? "fixed" : "absolute"} menuShouldBlockScroll={true} onBlur={(event) => { field.onBlur(event); 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..00e5ccfaea --- /dev/null +++ b/src/components/JobsHistory/JobHistoryOverridesDialog.tsx @@ -0,0 +1,331 @@ +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, 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); + + useEffect(() => { + if (open) { + setValues(initialValues); + } + }, [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/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/MCP/McpTabsLinks.tsx b/src/components/MCP/McpTabsLinks.tsx new file mode 100644 index 0000000000..a91b833754 --- /dev/null +++ b/src/components/MCP/McpTabsLinks.tsx @@ -0,0 +1,125 @@ +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 { Loading } from "@flanksource-ui/ui/Loading"; +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 McpTabsLinksProps = { + activeTab: + | "Overview" + | "Playbooks" + | "Views" + | "Subject access" + | "Check Access"; + children: React.ReactNode; + className?: string; + onRefresh?: () => void; + loading?: boolean; + isInitialLoading?: boolean; + loadingText?: string; + headerAction?: React.ReactNode; +}; + +export default function McpTabsLinks({ + activeTab, + children, + className, + onRefresh = () => {}, + loading = false, + isInitialLoading = false, + loadingText = "Loading...", + headerAction +}: McpTabsLinksProps) { + const [searchParams] = useSearchParams(); + + const tabLinks = useMemo(() => { + const query = searchParams.toString(); + const search = query ? `?${query}` : ""; + + return [ + { + label: "Overview", + path: "/settings/mcp/overview", + key: "Overview", + search + }, + { + label: "Subjects", + path: "/settings/mcp/subject-access", + key: "Subject access", + search + }, + { + label: "Playbooks", + path: "/settings/mcp/playbooks", + key: "Playbooks", + search + }, + { + label: "Views", + path: "/settings/mcp/views", + key: "Views", + search + }, + { + label: "Check Access", + path: "/settings/mcp/check-access", + key: "Check Access", + search + } + ]; + }, [searchParams]); + + return ( + <> + + + MCP + , + {activeTab}, + ...(headerAction ? [headerAction] : []) + ]} + /> + } + onRefresh={onRefresh} + loading={loading} + contentClass="p-0 h-full overflow-y-hidden" + > +
+
+ + + {isInitialLoading ? ( + + ) : ( + children + )} + + +
+ +
+
+ + ); +} diff --git a/src/components/MCP/UserList.tsx b/src/components/MCP/UserList.tsx new file mode 100644 index 0000000000..9de9fa186d --- /dev/null +++ b/src/components/MCP/UserList.tsx @@ -0,0 +1,83 @@ +import { + isSettingsManagedPermissionSource, + PermissionSubject +} from "@flanksource-ui/api/services/permissions"; +import { PermissionsSummary } from "@flanksource-ui/api/types/permissions"; +import SubjectAccessCard from "@flanksource-ui/components/Permissions/SubjectAccessCard"; + +const MCP_OBJECT = "mcp"; +const MCP_ACTION = "mcp:use"; + +const TYPE_LABELS: Record = { + person: "person", + access_token_person: "access token", + team: "team", + role: "role", + permission_subject_group: "group" +}; + +type GroupedSubject = { + type: PermissionSubject["type"]; + subjects: PermissionSubject[]; +}; + +type Props = { + groupedSubjects: GroupedSubject[]; + permissionsByUser: Map; + mutatingSubjectId: string | null; + onChangeAccess: ( + subject: PermissionSubject, + access: "allow" | "deny" | "default" + ) => void; +}; + +export default function UserList({ + groupedSubjects, + permissionsByUser, + mutatingSubjectId, + onChangeAccess +}: Props) { + return ( +
+ {groupedSubjects.map((group) => ( +
+
+ {TYPE_LABELS[group.type] ?? group.type} +
+ +
+ {group.subjects.map((subject) => { + const permissions = permissionsByUser.get(subject.id) ?? []; + + const activePermission = permissions.find((permission) => + isSettingsManagedPermissionSource(permission.source) + ); + + const access = !activePermission + ? "default" + : activePermission.deny === true + ? "deny" + : "allow"; + + return ( + onChangeAccess(subject, access)} + /> + ); + })} +
+
+ ))} +
+ ); +} diff --git a/src/components/Notifications/NotificationSendHistorySummary.tsx b/src/components/Notifications/NotificationSendHistorySummary.tsx index a3cdf2e849..e817e3bb6d 100644 --- a/src/components/Notifications/NotificationSendHistorySummary.tsx +++ b/src/components/Notifications/NotificationSendHistorySummary.tsx @@ -123,6 +123,12 @@ const notificationSendHistoryColumns: MRT_ColumnDef )} + {(row.original.in_progress ?? 0) > 0 && ( + + )}
); } 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/Permissions/EffectiveAccessBadge.tsx b/src/components/Permissions/EffectiveAccessBadge.tsx new file mode 100644 index 0000000000..24b0c9f3b6 --- /dev/null +++ b/src/components/Permissions/EffectiveAccessBadge.tsx @@ -0,0 +1,52 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from "@flanksource-ui/components/ui/tooltip"; +import { Check, X } from "lucide-react"; + +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."} + + + ); + } + + return ( + + {state === "allowed" ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/Permissions/ManagePermissions/Forms/FormikPermissionSelectResourceFields.tsx b/src/components/Permissions/ManagePermissions/Forms/FormikPermissionSelectResourceFields.tsx index 36cac0adf9..dbe8b168b3 100644 --- a/src/components/Permissions/ManagePermissions/Forms/FormikPermissionSelectResourceFields.tsx +++ b/src/components/Permissions/ManagePermissions/Forms/FormikPermissionSelectResourceFields.tsx @@ -19,7 +19,8 @@ export const permissionObjectList = [ { label: "RBAC", value: "rbac" }, { label: "Logs", value: "logs" }, { label: "Agent", value: "agent" }, - { label: "Artifact", value: "artifact" } + { label: "Artifact", value: "artifact" }, + { label: "MCP", value: "mcp" } ]; export default function FormikPermissionSelectResourceFields() { diff --git a/src/components/Permissions/ManagePermissions/Forms/PermissionForm.tsx b/src/components/Permissions/ManagePermissions/Forms/PermissionForm.tsx index 8c9f446ad6..ec52da681b 100644 --- a/src/components/Permissions/ManagePermissions/Forms/PermissionForm.tsx +++ b/src/components/Permissions/ManagePermissions/Forms/PermissionForm.tsx @@ -53,9 +53,14 @@ function PermissionActionDropdown({ isDisabled }: { isDisabled?: boolean }) { }, [values]); const availableActions = useMemo(() => { + // MCP channel-level permission is only valid on the global MCP object. + if (values.object === "mcp") { + return [{ value: "mcp:use", label: "mcp:use" }]; + } + const actions = getActionsForResourceType(resourceType); - // Add mcp:run when: playbook resource + specific playbook selected + person/team subject + // mcp:run is only valid for playbook permissions assigned to person/team subjects. if ( resourceType === "playbook" && values.playbook_id && @@ -65,7 +70,7 @@ function PermissionActionDropdown({ isDisabled }: { isDisabled?: boolean }) { } return actions; - }, [resourceType, values.playbook_id, values.subject_type]); + }, [resourceType, values.object, values.playbook_id, values.subject_type]); if (!resourceType) { return null; diff --git a/src/components/Permissions/PermissionAccessCheckModal.tsx b/src/components/Permissions/PermissionAccessCheckModal.tsx new file mode 100644 index 0000000000..14fe9eda4e --- /dev/null +++ b/src/components/Permissions/PermissionAccessCheckModal.tsx @@ -0,0 +1,598 @@ +import { fetchPermissionSubjects } from "@flanksource-ui/api/services/permissions"; +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + reviewSubjectAccess, + SubjectAccessReviewAction, + SubjectAccessReviewResource +} from "@flanksource-ui/api/services/rbac"; +import { getErrorMessage } from "@flanksource-ui/api/types/error"; +import { getAllViews } from "@flanksource-ui/api/services/views"; +import { Badge } from "@flanksource-ui/components/ui/badge"; +import { Button } from "@flanksource-ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue +} from "@flanksource-ui/components/ui/select"; +import SubjectAvatar from "@flanksource-ui/components/Permissions/SubjectAvatar"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Form, Formik } from "formik"; +import { useEffect, useMemo, useState } from "react"; + +export type PermissionAccessCheckResourceType = "playbook" | "view"; + +export type PermissionAccessCheckResource = { + type: PermissionAccessCheckResourceType; + id: string; + name?: string; +}; + +export type PermissionAccessCheckConfig = { + actions: SubjectAccessReviewAction[]; + title?: string; + description?: string; + resource?: PermissionAccessCheckResource; + lockedResource?: PermissionAccessCheckResource; + allowedResourceTypes?: PermissionAccessCheckResourceType[]; + hideResourceForActions?: SubjectAccessReviewAction[]; + resourceOverrideByAction?: Partial< + Record + >; +}; + +type PermissionAccessCheckModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + config: PermissionAccessCheckConfig; +}; + +type PermissionAccessCheckFormValues = { + resourceType: PermissionAccessCheckResourceType; + resourceId: string; + subjectId: string; + action: SubjectAccessReviewAction | ""; +}; + +type PermissionAccessCheckFormProps = { + config: PermissionAccessCheckConfig; + isActive?: boolean; + onCancel?: () => void; +}; + +type PermissionAccessResourceSelectorProps = { + resourceType: PermissionAccessCheckResourceType; + resourceId: string; + setFieldValue: (field: string, value: unknown) => void; + clearResult: () => void; + lockedResource?: PermissionAccessCheckResource; + allowedResourceTypes: PermissionAccessCheckResourceType[]; + isActive: boolean; +}; + +function PermissionAccessResourceSelector({ + resourceType, + resourceId, + setFieldValue, + clearResult, + lockedResource, + allowedResourceTypes, + isActive +}: PermissionAccessResourceSelectorProps) { + const shouldLoadPlaybooks = allowedResourceTypes.includes("playbook"); + const shouldLoadViews = allowedResourceTypes.includes("view"); + + const { data: playbooks = [] } = useQuery({ + queryKey: ["permission-access-check", "playbooks"], + queryFn: getAllPlaybookNames, + enabled: isActive && shouldLoadPlaybooks + }); + + const { data: viewsResponse } = useQuery({ + queryKey: ["permission-access-check", "views"], + queryFn: async () => getAllViews([{ id: "name", desc: false }], 0, 1000), + enabled: isActive && shouldLoadViews + }); + + const sortedPlaybooks = useMemo( + () => + [...playbooks].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ), + [playbooks] + ); + + const groupedPlaybooks = useMemo(() => { + const grouped = new Map(); + + for (const playbook of sortedPlaybooks) { + const category = playbook.category?.trim() || "Other"; + const list = grouped.get(category) ?? []; + list.push(playbook); + grouped.set(category, list); + } + + 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" }); + }); + }, [sortedPlaybooks]); + + const sortedViews = useMemo(() => { + const views = viewsResponse?.data ?? []; + + return [...views].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + }, [viewsResponse?.data]); + + useEffect(() => { + if (lockedResource) { + return; + } + + if (resourceType === "playbook") { + if (!resourceId && sortedPlaybooks.length > 0) { + setFieldValue("resourceId", sortedPlaybooks[0].id); + clearResult(); + } + return; + } + + if (!resourceId && sortedViews.length > 0) { + setFieldValue("resourceId", sortedViews[0].id); + clearResult(); + } + }, [ + clearResult, + lockedResource, + resourceId, + resourceType, + setFieldValue, + sortedPlaybooks, + sortedViews + ]); + + const selectedPlaybook = useMemo( + () => sortedPlaybooks.find((item) => item.id === resourceId), + [resourceId, sortedPlaybooks] + ); + + const selectedView = useMemo( + () => sortedViews.find((item) => item.id === resourceId), + [resourceId, sortedViews] + ); + + const selectedName = + resourceType === "playbook" + ? (selectedPlaybook?.name ?? lockedResource?.name) + : (selectedView?.name ?? lockedResource?.name); + const selectedNamespace = + resourceType === "playbook" + ? selectedPlaybook?.namespace + : selectedView?.namespace; + + return ( +
+ {allowedResourceTypes.length > 1 ? ( +
+

Resource Type

+ + options={allowedResourceTypes} + value={resourceType} + onChange={(value) => { + if (lockedResource) { + return; + } + + setFieldValue("resourceType", value); + setFieldValue("resourceId", ""); + clearResult(); + }} + className="w-full" + /> +
+ ) : null} + +
+

Resource

+ +
+
+ ); +} + +export function PermissionAccessCheckForm({ + config, + isActive = true, + onCancel +}: PermissionAccessCheckFormProps) { + const [requestError, setRequestError] = useState(); + const [result, setResult] = useState<{ + allowed: boolean; + error?: string; + }>(); + + const lockedResource = config.lockedResource ?? config.resource; + const allowedResourceTypes = useMemo(() => { + if (lockedResource) { + return [lockedResource.type] as PermissionAccessCheckResourceType[]; + } + + if (config.allowedResourceTypes && config.allowedResourceTypes.length > 0) { + return config.allowedResourceTypes; + } + + return ["playbook", "view"] as PermissionAccessCheckResourceType[]; + }, [config.allowedResourceTypes, lockedResource]); + + const initialResourceType = + lockedResource?.type ?? allowedResourceTypes[0] ?? "playbook"; + + const { data: subjects = [], isLoading: isLoadingSubjects } = useQuery({ + queryKey: ["permission-subjects", "access-check-form"], + queryFn: fetchPermissionSubjects, + enabled: isActive + }); + + const { mutateAsync: checkAccess, isLoading: isCheckingAccess } = useMutation( + { + mutationFn: reviewSubjectAccess + } + ); + + useEffect(() => { + if (!isActive) { + setResult(undefined); + setRequestError(undefined); + } + }, [isActive]); + + const initialValues: PermissionAccessCheckFormValues = useMemo( + () => ({ + resourceType: initialResourceType, + resourceId: lockedResource?.id ?? "", + subjectId: "", + action: config.actions[0] ?? "" + }), + [config.actions, initialResourceType, lockedResource?.id] + ); + + return ( + + initialValues={initialValues} + enableReinitialize + onSubmit={async (values, helpers) => { + if (!values.subjectId) { + helpers.setFieldError("subjectId", "Subject is required"); + return; + } + + if (!values.action) { + helpers.setFieldError("action", "Action is required"); + return; + } + + const resourceOverride = values.action + ? config.resourceOverrideByAction?.[values.action] + : undefined; + const shouldHideResourceSelector = values.action + ? !!config.hideResourceForActions?.includes(values.action) + : false; + + if ( + !resourceOverride && + !shouldHideResourceSelector && + !values.resourceId + ) { + helpers.setFieldError("resourceId", "Resource is required"); + return; + } + + setRequestError(undefined); + setResult(undefined); + + try { + const response = await checkAccess({ + resource: + resourceOverride ?? + (values.resourceType === "playbook" + ? { playbook: values.resourceId } + : { view: values.resourceId }), + action: values.action, + subjects: [values.subjectId] + }); + + const firstResult = response?.results?.[0]; + setResult( + firstResult + ? { allowed: firstResult.allowed, error: firstResult.error } + : undefined + ); + } catch (error) { + setRequestError(getErrorMessage(error)); + setResult(undefined); + } finally { + helpers.setSubmitting(false); + } + }} + > + {({ values, setFieldValue, errors, isSubmitting }) => { + const selectedSubject = subjects.find( + (subject) => subject.id === values.subjectId + ); + + const shouldHideResourceSelector = values.action + ? !!config.hideResourceForActions?.includes(values.action) + : false; + + const resourceOverride = values.action + ? config.resourceOverrideByAction?.[values.action] + : undefined; + + const overrideResourceLabel = resourceOverride?.global + ? resourceOverride.global.toUpperCase() + : (resourceOverride?.playbook ?? resourceOverride?.view); + + const isCheckDisabled = + !values.subjectId || + !values.action || + (!resourceOverride && + !shouldHideResourceSelector && + !values.resourceId) || + isCheckingAccess || + isSubmitting; + + const clearResult = () => { + setResult(undefined); + setRequestError(undefined); + }; + + return ( +
+
+

Subject

+ + {errors.subjectId ? ( +

{errors.subjectId}

+ ) : null} +
+ +
+

Action

+ + {errors.action ? ( +

{errors.action}

+ ) : null} +
+ + {!shouldHideResourceSelector ? ( + <> + + {errors.resourceId ? ( +

+ {errors.resourceId} +

+ ) : null} + + ) : ( +
+

Resource

+

+ {overrideResourceLabel ?? "N/A"} +

+
+ )} + + {requestError ? ( +
+ {requestError} +
+ ) : null} + + {result ? ( +
+ {selectedSubject?.name ?? "Subject"} is + {result.allowed ? " allowed " : " not allowed "} + to perform {values.action} + {result.error ? ` (${result.error})` : ""} +
+ ) : null} + +
+ {onCancel ? ( + + ) : 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/PermissionResourceCell.tsx b/src/components/Permissions/PermissionResourceCell.tsx new file mode 100644 index 0000000000..d55948ba54 --- /dev/null +++ b/src/components/Permissions/PermissionResourceCell.tsx @@ -0,0 +1,279 @@ +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 { 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; +}; + +const OBJECT_LABELS: Record = { + catalog: "Catalog", + component: "Component", + canaries: "Canaries", + connection: "Connection", + playbook: "Playbook", + topology: "Topology", + mcp: "MCP" +}; + +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; + const hasConcreteObject = Boolean( + config || playbook || component || canary || connection + ); + + 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 (hasConcreteObject) { + return ( +
+
+
+ {config && ( +
+ Catalog: + +
+ )} + + {playbook && ( +
+ Playbook: + +
+ )} + + {component && ( +
+ Component: + +
+ )} + + {canary && ( +
+ Canary: + +
+ )} + + {connection && ( +
+ Connection: + +
+ )} +
+
+ +
+ ); + } + + if (object) { + return ( +
+
+ {OBJECT_LABELS[object] ?? object} +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Permissions/PermissionSubjectPanel.tsx b/src/components/Permissions/PermissionSubjectPanel.tsx new file mode 100644 index 0000000000..31b57ceb51 --- /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/PermissionsTable.tsx b/src/components/Permissions/PermissionsTable.tsx index 42ebf51277..132abb61f7 100644 --- a/src/components/Permissions/PermissionsTable.tsx +++ b/src/components/Permissions/PermissionsTable.tsx @@ -4,33 +4,20 @@ 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"; - -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 FilterByCellValue from "@flanksource-ui/ui/DataTable/FilterByCellValue"; +import PermissionResourceCell from "./PermissionResourceCell"; const permissionsTableColumns: MRT_ColumnDef[] = [ { + id: "subject", + accessorFn: (row) => row.subject, header: "Subject", - size: 100, + size: 80, Cell: ({ row }) => { const { team, group, person, subject, notification, playbook } = row.original; @@ -108,143 +95,16 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ id: "Resource", header: "Resource", 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 }) => }, { id: "action", + accessorFn: (row) => row.action, header: "Action", - size: 40, + size: 70, Cell: ({ row }) => { const action = row.original.action; const deny = row.original.deny; @@ -254,7 +114,7 @@ const permissionsTableColumns: MRT_ColumnDef[] = [ )?.label; return ( -
+ [] = [ /> )} -
+ ); } }, { id: "description", header: "Description", + enableSorting: false, size: 200, accessorFn: (row) => row.description }, { - id: "updated", + id: "updated_at", size: 40, header: "Updated", accessorFn: (row) => row.updated_at, Cell: MRTDateCell }, { - id: "created", + id: "created_at", size: 40, header: "Created", accessorFn: (row) => row.created_at, Cell: MRTDateCell }, { - id: "createdBy", + 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; @@ -320,6 +182,7 @@ type PermissionsTableProps = { totalEntries: number; handleRowClick?: (row: PermissionsSummary) => void; hideResourceColumn?: boolean; + hideSubjectColumn?: boolean; }; export default function PermissionsTable({ @@ -328,18 +191,27 @@ 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/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 13e34756e6..616ea43c59 100644 --- a/src/components/Permissions/PermissionsView.tsx +++ b/src/components/Permissions/PermissionsView.tsx @@ -7,11 +7,17 @@ import { PermissionTable } from "@flanksource-ui/api/types/permissions"; 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"; import PermissionForm from "./ManagePermissions/Forms/PermissionForm"; +import PermissionAccessCheckModal, { + PermissionAccessCheckConfig +} from "./PermissionAccessCheckModal"; import PermissionsTable from "./PermissionsTable"; // Source: github.com/flanksource/duty/rbac/policy/policy.go @@ -25,7 +31,8 @@ export const permissionsActionsList: FormikSelectDropdownOption[] = [ { value: "playbook:run", label: "playbook:run" }, { value: "playbook:approve", label: "playbook:approve" }, { value: "playbook:*", label: "playbook:*" }, - { value: "mcp:run", label: "mcp:run" } + { value: "mcp:run", label: "mcp:run" }, + { value: "mcp:use", label: "mcp:use" } ]; const commonActions: FormikSelectDropdownOption[] = [ @@ -74,37 +81,56 @@ type PermissionsViewProps = { permissionRequest: FetchPermissionsInput; setIsLoading?: (isLoading: boolean) => void; hideResourceColumn?: boolean; + hideSubjectColumn?: boolean; newPermissionData?: Partial; showAddPermission?: boolean; onRefetch?: (refetch: () => void) => void; + accessCheckConfig?: PermissionAccessCheckConfig; }; export default function PermissionsView({ permissionRequest, setIsLoading = () => {}, hideResourceColumn = false, + 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 { isLoading, data, refetch } = useQuery({ + const mappedSortBy = + sortState[0]?.id === "created" + ? "created_at" + : sortState[0]?.id === "updated" + ? "updated_at" + : sortState[0]?.id === "createdBy" + ? "created_by" + : sortState[0]?.id; + + const { isLoading, data, refetch, isError, error } = useQuery({ queryKey: [ "permissions_summary", permissionRequest, { pageIndex, - pageSize + pageSize, + sortBy: mappedSortBy, + sortOrder: sortState[0]?.desc ? "desc" : "asc" } ], queryFn: () => fetchPermissions(permissionRequest, { pageIndex, - pageSize + pageSize, + sortBy: mappedSortBy, + sortOrder: sortState[0]?.desc ? "desc" : "asc" }), keepPreviousData: true }); @@ -113,6 +139,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); @@ -124,26 +156,41 @@ export default function PermissionsView({ const permissions = data?.data || []; return ( - <> +
{showAddPermission && ( -
- +
+
+ + {accessCheckConfig ? ( + + ) : null} +
)} - setSelectedPermission(row)} - hideResourceColumn={hideResourceColumn} - /> +
+ setSelectedPermission(row)} + hideResourceColumn={hideResourceColumn} + hideSubjectColumn={hideSubjectColumn} + /> +
{selectedPermission && ( )} - + {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..76a74c2f09 --- /dev/null +++ b/src/components/Permissions/ResourceSelectorPanel.tsx @@ -0,0 +1,294 @@ +import { + isSettingsManagedPermissionSource, + 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" && + isSettingsManagedPermissionSource(permission.source) && + 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" || + !isSettingsManagedPermissionSource(permission.source) || + 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/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/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/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/PlaybookCardMenu.tsx b/src/components/Playbooks/Settings/PlaybookCardMenu.tsx index 9ff043f9c0..6d30545b80 100644 --- a/src/components/Playbooks/Settings/PlaybookCardMenu.tsx +++ b/src/components/Playbooks/Settings/PlaybookCardMenu.tsx @@ -1,18 +1,20 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { DotsVerticalIcon } from "@heroicons/react/solid"; import { BsTrash } from "react-icons/bs"; -import { MdEdit } from "react-icons/md"; +import { MdEdit, MdSecurity } from "react-icons/md"; import { IconButton } from "../../../ui/Buttons/IconButton"; type PlaybookCardMenuDropdownProps = { onDeletePlaybook?: () => void; onEditPlaybook?: () => void; + onManagePermissions?: () => void; onHistory?: () => void; }; export default function PlaybookCardMenuDropdown({ onDeletePlaybook = () => {}, - onEditPlaybook = () => {} + onEditPlaybook = () => {}, + onManagePermissions = () => {} }: PlaybookCardMenuDropdownProps) { return ( @@ -22,11 +24,11 @@ export default function PlaybookCardMenuDropdown({ { onEditPlaybook(); }} @@ -43,7 +45,7 @@ export default function PlaybookCardMenuDropdown({ icon={ } /> @@ -52,7 +54,22 @@ export default function PlaybookCardMenuDropdown({ { + onManagePermissions(); + }} + > + <> + + Permissions + + + { onDeletePlaybook(); }} @@ -60,7 +77,7 @@ export default function PlaybookCardMenuDropdown({ <> Delete diff --git a/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx new file mode 100644 index 0000000000..fee2589995 --- /dev/null +++ b/src/components/Playbooks/Settings/PlaybookPermissionsModal.tsx @@ -0,0 +1,147 @@ +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"; +import FlatTabs from "@flanksource-ui/ui/Tabs/FlatTabs"; +import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import PlaybookSpecModalTitle from "../PlaybookSpecModalTitle"; + +type PlaybookPermissionsModalProps = { + playbook: PlaybookSpec; + isOpen: boolean; + onClose: () => void; +}; + +const playbookAccessReviewActions: SubjectAccessReviewAction[] = [ + "read", + "playbook:run", + "playbook:cancel", + "playbook:approve" +]; + +export default function PlaybookPermissionsModal({ + playbook, + isOpen, + onClose +}: PlaybookPermissionsModalProps) { + 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 ( + + } + onClose={onClose} + open={isOpen} + size="full" + containerClassName="h-full overflow-hidden" + bodyClass="flex w-full min-h-0 flex-1 flex-col" + helpLink="playbooks" + > + { + setActiveTab(label); + + const permissionRequest = + label === "who-can-run" + ? inboundPermissionRequest + : outboundPermissionRequest; + + queryClient.invalidateQueries({ + queryKey: ["permissions_summary", permissionRequest] + }); + }} + contentClassName="px-4 pb-4" + tabs={[ + { + label: "Inbound", + key: "who-can-run", + current: activeTab === "who-can-run", + content: ( + + ) + }, + { + label: "Outbound", + key: "what-it-can-do", + current: activeTab === "what-it-can-do", + content: ( + + ) + } + ]} + /> + + ); +} diff --git a/src/components/Playbooks/Settings/PlaybookSpecCard.tsx b/src/components/Playbooks/Settings/PlaybookSpecCard.tsx index 09db25e629..4cf029621a 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecCard.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecCard.tsx @@ -1,6 +1,7 @@ import { useGetPlaybookSpecsDetails } from "@flanksource-ui/api/query-hooks/playbooks"; import { AuthorizationAccessCheck } from "@flanksource-ui/components/Permissions/AuthorizationAccessCheck"; import { Button } from "@flanksource-ui/components/ui/button"; +import { ConfirmationPromptDialog } from "@flanksource-ui/ui/AlertDialog/ConfirmationPromptDialog"; import { Card, CardContent, @@ -20,6 +21,7 @@ import { Icon } from "../../../ui/Icons/Icon"; import { toastError, toastSuccess } from "../../Toast/toast"; import SubmitPlaybookRunForm from "../Runs/Submit/SubmitPlaybookRunForm"; import PlaybookCardMenu from "./PlaybookCardMenu"; +import PlaybookPermissionsModal from "./PlaybookPermissionsModal"; import PlaybookSpecFormModal from "./PlaybookSpecFormModal"; type PlaybookSpecCardProps = { @@ -34,9 +36,14 @@ export default function PlaybookSpecCard({ const [isEditPlaybookFormOpen, setIsEditPlaybookFormOpen] = useState(false); const [isSubmitPlaybookRunFormOpen, setIsSubmitPlaybookRunFormOpen] = useState(false); + const [isPermissionsModalOpen, setIsPermissionsModalOpen] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const { data: playbookSpec } = useGetPlaybookSpecsDetails(playbook.id, { - enabled: isEditPlaybookFormOpen || isSubmitPlaybookRunFormOpen + enabled: + isEditPlaybookFormOpen || + isSubmitPlaybookRunFormOpen || + isPermissionsModalOpen }); const { permissions, roles } = useUser(); @@ -52,7 +59,8 @@ export default function PlaybookSpecCard({ return res; }, onSuccess: () => { - toastSuccess("Playbook Spec updated successfully"); + toastSuccess("Playbook Spec deleted successfully"); + setIsDeleteConfirmOpen(false); refetch(); }, onError: (err: Error) => { @@ -65,7 +73,7 @@ export default function PlaybookSpecCard({ - + {/* For now, default to name, when title isn't there */} {playbook.title || playbook.name} @@ -76,7 +84,8 @@ export default function PlaybookSpecCard({ > setIsEditPlaybookFormOpen(true)} - onDeletePlaybook={() => deletePlaybook(playbook.id)} + onManagePermissions={() => setIsPermissionsModalOpen(true)} + onDeletePlaybook={() => setIsDeleteConfirmOpen(true)} /> @@ -124,6 +133,14 @@ export default function PlaybookSpecCard({ /> )} + {playbookSpec && ( + setIsPermissionsModalOpen(false)} + playbook={playbookSpec} + /> + )} + {playbookSpec && ( )} + + setIsDeleteConfirmOpen(false)} + onConfirm={() => deletePlaybook(playbook.id)} + confirmationStyle="delete" + yesLabel="Delete" + /> ); } diff --git a/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx b/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx index a008ac20e0..71b77ceb25 100644 --- a/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx +++ b/src/components/Playbooks/Settings/PlaybookSpecFormModal.tsx @@ -1,8 +1,5 @@ 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 PlaybookSpecModalTitle from "../PlaybookSpecModalTitle"; import PlaybookSpecsForm from "./PlaybookSpecsForm"; @@ -19,8 +16,6 @@ export default function PlaybookSpecFormModal({ onClose, ...props }: PlaybookSpecFormModalProps) { - const [activeTab, setActiveTab] = useState<"form" | "permissions">("form"); - return ( - {playbook?.id ? ( - setActiveTab(label)} - tabs={[ - { - label: "Edit Playbook Spec", - key: "form", - current: activeTab === "form", - content: ( - - ) - }, - { - label: "Permissions", - key: "permissions", - current: activeTab === "permissions", - content: ( - - ) - } - ]} - /> - ) : ( - - )} + ); } 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) => (
([ ["trivy", "trivy"], ["aws", "aws"], ["file", "folder"], + ["gcp", "gcp"], + ["github", "github"], ["githubActions", "github"], ["http", "http"], ["azureDevops", "azure-devops"], diff --git a/src/components/Scopes/Impersonation/ScopeImpersonationModal.tsx b/src/components/Scopes/Impersonation/ScopeImpersonationModal.tsx new file mode 100644 index 0000000000..099f2e35c7 --- /dev/null +++ b/src/components/Scopes/Impersonation/ScopeImpersonationModal.tsx @@ -0,0 +1,204 @@ +// ABOUTME: Modal for selecting scopes to impersonate or defining custom targets. +// ABOUTME: Persists selections in sessionStorage, attaching them as a header on all API requests. + +import { useScopesQuery } from "@flanksource-ui/api/query-hooks/useScopesQuery"; +import { + ScopeDisplay, + ScopeTargetForm +} from "@flanksource-ui/api/types/scopes"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { Modal } from "@flanksource-ui/ui/Modal"; +import { Form, Formik } from "formik"; +import { useMemo, useState } from "react"; +import Select from "react-select"; +import ScopeTargetsForm from "../Forms/ScopeTargetsForm"; +import { ImpersonationMode } from "./scopeImpersonationStore"; +import { useImpersonatedScopes } from "./useImpersonatedScopes"; + +type ScopeOption = { + value: string; + label: string; + scope: ScopeDisplay; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const DEFAULT_TARGET: ScopeTargetForm = { + config: { + name: "", + agent: "", + tagSelector: "", + tags: {}, + wildcard: false + } +}; + +const MODE_OPTIONS = ["Existing Scopes", "Custom Targets"] as const; + +function modeToOption(mode: ImpersonationMode): (typeof MODE_OPTIONS)[number] { + return mode === "scopes" ? "Existing Scopes" : "Custom Targets"; +} + +function optionToMode(option: string): ImpersonationMode { + return option === "Existing Scopes" ? "scopes" : "targets"; +} + +export default function ScopeImpersonationModal({ isOpen, onClose }: Props) { + const { data: scopes, isLoading } = useScopesQuery(); + const { + scopeIds: impersonatedIds, + targets: impersonatedTargets, + mode: storedMode, + active, + setScopes, + setTargets, + clear + } = useImpersonatedScopes(); + + const [mode, setMode] = useState("scopes"); + const [selected, setSelected] = useState([]); + const [initialized, setInitialized] = useState(false); + + const options: ScopeOption[] = useMemo(() => { + if (!scopes) return []; + return scopes.map((s) => ({ + value: s.id, + label: s.namespace ? `${s.namespace}/${s.name}` : s.name, + scope: s + })); + }, [scopes]); + + // Sync local state from stored impersonation when modal opens + if (isOpen && !initialized) { + setMode(storedMode); + const initial = options.filter((o) => impersonatedIds.includes(o.value)); + setSelected(initial); + setInitialized(true); + } + if (!isOpen && initialized) { + setInitialized(false); + } + + function handleApplyScopes() { + setScopes(selected.map((o) => o.scope)); + onClose(); + } + + function handleReset() { + clear(); + setSelected([]); + onClose(); + } + + const initialTargets = + storedMode === "targets" && impersonatedTargets.length > 0 + ? impersonatedTargets + : [DEFAULT_TARGET]; + + return ( + +
+ setMode(optionToMode(val))} + /> + + {active && ( +

+ Scope impersonation is active ( + {storedMode === "scopes" + ? `${impersonatedIds.length} scope${impersonatedIds.length !== 1 ? "s" : ""}` + : "custom targets"} + ) +

+ )} + + {mode === "scopes" ? ( + <> +

+ Select existing scopes to impersonate. +

+ 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..7e7f85dd58 --- /dev/null +++ b/src/pages/Settings/mcp/McpSubjectAccessPage.tsx @@ -0,0 +1,571 @@ +import { getAllPlaybookNames } from "@flanksource-ui/api/services/playbooks"; +import { + addPermission, + deletePermission, + fetchAllPermissionSubjects, + fetchMcpRunPermissions, + INTERACTIVE_SETTINGS_PERMISSION_SOURCE, + isSettingsManagedPermissionSource, + 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]); + + 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" && + isSettingsManagedPermissionSource(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: INTERACTIVE_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" || + !isSettingsManagedPermissionSource(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: INTERACTIVE_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/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() {
{ refetch(); + resetInviteError(); setIsOpen(false); setInviteLink(data.link); - }, - onError: (error) => { - toastError(error.message); } }); @@ -109,7 +113,10 @@ export function UsersPage() { > - 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/pages/playbooks/PlaybooksList.tsx b/src/pages/playbooks/PlaybooksList.tsx index 4ee94ddfef..2028c76f11 100644 --- a/src/pages/playbooks/PlaybooksList.tsx +++ b/src/pages/playbooks/PlaybooksList.tsx @@ -59,7 +59,7 @@ export function PlaybooksListPage() { loading={isLoading} > -
+
{error && !playbooks ? ( ) : ( 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} diff --git a/src/pages/views/components/ViewContainer.tsx b/src/pages/views/components/ViewContainer.tsx index 54b9876184..f162b13748 100644 --- a/src/pages/views/components/ViewContainer.tsx +++ b/src/pages/views/components/ViewContainer.tsx @@ -21,6 +21,7 @@ const ViewContainer: React.FC = ({ id }) => { viewResult, isLoading, isFetching, + isPreviousData, error, aggregatedVariables, currentVariables, @@ -32,6 +33,7 @@ const ViewContainer: React.FC = ({ id }) => { const refreshError = viewResult?.refreshStatus === "error" ? viewResult.refreshError : undefined; const isCachedResponse = viewResult?.responseSource === "cache"; + const showTransitionOverlay = isFetching && isPreviousData; useEffect(() => { if (refreshError && isCachedResponse) { @@ -116,13 +118,24 @@ const ViewContainer: React.FC = ({ id }) => { ) } > - +
+ + + {showTransitionOverlay && ( +
+
+
+ Loading view +
+
+ )} +
); diff --git a/src/pages/views/hooks/useViewData.ts b/src/pages/views/hooks/useViewData.ts index 2e38760bbd..fb80eb4002 100644 --- a/src/pages/views/hooks/useViewData.ts +++ b/src/pages/views/hooks/useViewData.ts @@ -32,6 +32,7 @@ export interface UseViewDataResult { viewResult: ViewResult | undefined; isLoading: boolean; isFetching: boolean; + isPreviousData: boolean; error: unknown; aggregatedVariables: ViewVariable[]; currentVariables: Record; @@ -124,6 +125,7 @@ export function useViewData({ data: viewResult, isLoading: isLoadingViewResult, isFetching: isFetchingViewResult, + isPreviousData, error: viewResultError, refetch } = useQuery({ @@ -404,6 +406,7 @@ export function useViewData({ viewResult, isLoading: isLoadingViewResult || isLoadingDisplayPluginVariables, isFetching: isFetchingViewResult || isFetchingDisplayPluginVariables, + isPreviousData, error: displayPluginVariablesError || viewResultError, aggregatedVariables: isDisplayPluginMode ? EMPTY_VARIABLES 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/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/AlertDialog/ConfirmationPromptDialog.tsx b/src/ui/AlertDialog/ConfirmationPromptDialog.tsx index bbdd23b864..2a2183cd92 100644 --- a/src/ui/AlertDialog/ConfirmationPromptDialog.tsx +++ b/src/ui/AlertDialog/ConfirmationPromptDialog.tsx @@ -1,9 +1,15 @@ -import { Dialog } from "@headlessui/react"; -import React, { ComponentProps } from "react"; -import { FaCircleNotch } from "react-icons/fa"; -import { Modal } from "../Modal"; -import clsx from "clsx"; import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@flanksource-ui/components/ui/dialog"; +import clsx from "clsx"; +import React from "react"; +import { FaCircleNotch } from "react-icons/fa"; export type ConfirmationPromptDialogProps = { isOpen: boolean; @@ -16,7 +22,8 @@ export type ConfirmationPromptDialogProps = { isLoading?: boolean; confirmationStyle?: "delete" | "approve"; error?: unknown; -} & ComponentProps; + className?: string; +}; export function ConfirmationPromptDialog({ title, @@ -29,100 +36,103 @@ export function ConfirmationPromptDialog({ closeLabel = "Cancel", isLoading = false, error, - className, - ...rest + className }: ConfirmationPromptDialogProps) { return ( - -
- {confirmationStyle === "delete" && ( - - )} - {confirmationStyle === "approve" && ( - - )} -
-
-
- -
-

{description}

-
-
- {error ? : null} -
-
-
-
- } - titleClass="text-gray-900" - titleHeaderClass=" px-4" - size="very-small" - onClose={onClose} - {...rest} + onOpenChange={(open) => { + if (!open) { + onClose(); + } + }} > -
-
-
- - +
+
+ + {title} + + + {description} + + {error ? ( +
+ +
+ ) : null} +
-
-
- + + + + + + + + ); } 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}({ onChange = () => {}, ...props }: SelectDropdownProps) { + const menuPortalTarget = + typeof document !== "undefined" ? document.body : undefined; + return (
-
+
-
- {tabs.find((tab) => tab.current)?.content} +
+ {tabs.find((tab) => tab.current)?.content} +
);