From 050a25bc74641488000e4fc5413c8528d5ef557c Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 9 Dec 2024 14:17:37 +0530 Subject: [PATCH 01/25] added graph command --- permit-graph.html | 238 +++++++++++++++++++++++++ source/commands/graph.tsx | 23 +++ source/components/HtmlGraphSaver.ts | 117 ++++++++++++ source/components/generateGraphData.ts | 81 +++++++++ source/components/graphCommand.tsx | 228 +++++++++++++++++++++++ source/lib/api.ts | 1 + 6 files changed, 688 insertions(+) create mode 100644 permit-graph.html create mode 100644 source/commands/graph.tsx create mode 100644 source/components/HtmlGraphSaver.ts create mode 100644 source/components/generateGraphData.ts create mode 100644 source/components/graphCommand.tsx diff --git a/permit-graph.html b/permit-graph.html new file mode 100644 index 0000000..d4c760e --- /dev/null +++ b/permit-graph.html @@ -0,0 +1,238 @@ + + + + + + + ReBAC Graph + + + + + + +
Permit ReBAC Graph
+
+ + + diff --git a/source/commands/graph.tsx b/source/commands/graph.tsx new file mode 100644 index 0000000..cdf6907 --- /dev/null +++ b/source/commands/graph.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { AuthProvider } from '../components/AuthProvider.js'; +import Graph from '../components/graphCommand.js'; +import { type infer as zInfer, object, string } from 'zod'; +import { option } from 'pastel'; + +export const options = object({ + apiKey: string() + .optional() + .describe(option({ description: 'The API key for the Permit env, project or Workspace' })), +}); + +type Props = { + options: zInfer; +}; + +export default function graph({ options }: Props) { + return ( + + + + ); +} diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts new file mode 100644 index 0000000..07aeed3 --- /dev/null +++ b/source/components/HtmlGraphSaver.ts @@ -0,0 +1,117 @@ +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; +import open from 'open'; + +export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { + const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); + const htmlTemplate = ` + + + + + + ReBAC Graph + + + + + + +
Permit ReBAC Graph
+
+ + + +`; + writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); + console.log(`Graph saved as: ${outputHTMLPath}`); + open(outputHTMLPath); +}; diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts new file mode 100644 index 0000000..ebe1744 --- /dev/null +++ b/source/components/generateGraphData.ts @@ -0,0 +1,81 @@ +// Define types +type ResourceInstance = { + label: string; + value: string; + id: string; +}; + +type Relationship = { + label: string; + value: string; +}; + +type RoleAssignment = { + user: string; + role: string; + resourceInstance: string; +}; + + +// Generate Graph Data +export const generateGraphData = ( + resources: ResourceInstance[], + relationships: Map, + roleAssignments: RoleAssignment[] +) => { + const nodes = resources.map((resource) => ({ + data: { id: resource.id, label: `Resource: ${resource.label}` }, + })); + + const edges: { data: { source: string; target: string; label: string } }[] = []; + const existingNodeIds = new Set(nodes.map((node) => node.data.id)); + + relationships.forEach((relations, resourceId) => { + relations.forEach((relation) => { + if (!existingNodeIds.has(relation.value)) { + nodes.push({ data: { id: relation.value, label: `Resource: ${relation.value}` } }); + existingNodeIds.add(relation.value); + } + + edges.push({ + data: { source: resourceId, target: relation.value, label: relation.label }, + }); + }); + }); + + // Add role assignments to the graph + roleAssignments.forEach((assignment) => { + // Add user nodes + if (!existingNodeIds.has(assignment.user)) { + nodes.push({ data: { id: assignment.user, label: `User: ${assignment.user}` } }); + existingNodeIds.add(assignment.user); + } + + // Add role nodes + const roleNodeId = `role:${assignment.role}`; + if (!existingNodeIds.has(roleNodeId)) { + nodes.push({ data: { id: roleNodeId, label: `Role: ${assignment.role}` } }); + existingNodeIds.add(roleNodeId); + } + + // Connect user to role + edges.push({ + data: { + source: assignment.user, + target: roleNodeId, + label: `Assigned role`, + }, + }); + + // Connect role to resource instance + edges.push({ + data: { + source: roleNodeId, + target: assignment.resourceInstance, + label: `Grants access`, + }, + }); + }); + + return { nodes, edges }; +}; diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx new file mode 100644 index 0000000..b992b1a --- /dev/null +++ b/source/components/graphCommand.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import { apiCall } from '../lib/api.js'; +import { saveHTMLGraph } from '../components/HtmlGraphSaver.js'; +import { generateGraphData } from '../components/generateGraphData.js'; +import zod from 'zod'; +import { option } from 'pastel'; +import { useAuth } from '../components/AuthProvider.js'; // Import useAuth + +// Define types +type Relationship = { + label: string; + value: string; +}; + +type RoleAssignment = { + user: string; + role: string; + resourceInstance: string; +}; + +export const options = zod.object({ + apiKey: zod + .string() + .optional() + .describe( + option({ + description: 'The API key for the Permit env, project or Workspace', + }), + ), +}); + +type Props = { + options: zod.infer; +}; + +export default function Graph({ options }: Props) { + const { authToken: contextAuthToken, loading: authLoading, error: authError } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [authToken, setAuthToken] = useState(null); // Store resolved authToken + const [state, setState] = useState<'project' | 'environment' | 'graph'>('project'); + const [projects, setProjects] = useState<[]>([]); + const [environments, setEnvironments] = useState<[]>([]); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedEnvironment, setSelectedEnvironment] = useState(null); + + // Resolve the authToken on mount + useEffect(() => { + const token = contextAuthToken || options.apiKey || null; + if (!token) { + setError('No auth token found. Please log in or provide an API key.'); + } else { + setAuthToken(token); + } + }, [contextAuthToken, options.apiKey]); + + // Fetch projects + useEffect(() => { + const fetchProjects = async () => { + if (!authToken) return; + + try { + setLoading(true); + const { response: projects } = await apiCall('v2/projects', authToken); + setProjects( + projects['map']((project: any) => ({ + label: project.name, + value: project.id, + })) + ); + setLoading(false); + } catch (err) { + console.error('Error fetching projects:', err); + setError('Failed to fetch projects.'); + setLoading(false); + } + }; + + if (state === 'project') { + fetchProjects(); + } + }, [state, authToken]); + + // Fetch environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!authToken || !selectedProject) return; + + try { + setLoading(true); + const { response: environments } = await apiCall( + `v2/projects/${selectedProject.value}/envs`, + authToken + ); + setEnvironments( + environments['map']((env: any) => ({ + label: env.name, + value: env.id, + })) + ); + setLoading(false); + } catch (err) { + console.error('Error fetching environments:', err); + setError('Failed to fetch environments.'); + setLoading(false); + } + }; + + if (state === 'environment') { + fetchEnvironments(); + } + }, [state, authToken, selectedProject]); + + // Fetch graph data + useEffect(() => { + const fetchData = async () => { + if (!authToken || !selectedProject || !selectedEnvironment) return; + + try { + setLoading(true); + + const resourceResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true`, + authToken + ); + + const resourcesData = resourceResponse.response['map']((res: any) => ({ + label: res.resource, + value: res.id, + id: res.id, + })); + + const relationsMap = new Map(); + resourceResponse.response['forEach']((resource: any) => { + const relationsData = resource.relationships || []; + relationsMap.set( + resource.id, + relationsData.map((relation: any) => ({ + label: `${relation.relation} → ${relation.object}`, + value: relation.object || 'Unknown ID', + })) + ); + }); + + const roleAssignmentsData: RoleAssignment[] = []; + for (const resource of resourcesData) { + const roleResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/role_assignments?resource_instance=${resource.id}`, + authToken + ); + + roleAssignmentsData.push( + ...roleResponse.response['map']((role: any) => ({ + user: role.user || 'Unknown User', + role: role.role || 'Unknown Role', + resourceInstance: resource.id, + })) + ); + } + + const graphData = generateGraphData(resourcesData, relationsMap, roleAssignmentsData); + saveHTMLGraph(graphData); + setLoading(false); + } catch (err) { + console.error('Error fetching graph data:', err); + setError('Failed to fetch data. Check network or auth token.'); + setLoading(false); + } + }; + + if (state === 'graph') { + fetchData(); + } + }, [state, authToken, selectedProject, selectedEnvironment]); + + // Loading and error states + if (authLoading || loading) { + return ( + + {authLoading ? 'Authenticating...' : 'Loading Permit Graph...'} + + ); + } + + if (authError || error) { + return {authError || error}; + } + + // State rendering + if (state === 'project' && projects.length > 0) { + return ( + <> + Select a project + { + setSelectedProject(project); + setState('environment'); + }} + /> + + ); + } + + if (state === 'environment' && environments.length > 0) { + return ( + <> + Select an environment + { + setSelectedEnvironment(environment); + setState('graph'); + }} + /> + + ); + } + + if (state === 'graph') { + return Graph generated successfully and saved as HTML!; + } + + return Initializing...; +} diff --git a/source/lib/api.ts b/source/lib/api.ts index ddf0e6f..da90c4c 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,6 +1,7 @@ import { PERMIT_API_URL } from '../config.js'; interface ApiResponseData { + [x: string]: any; id?: string; name?: string; } From c5a5fe6412f864bc830d9d4b94ce53b6d4b25e50 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 9 Dec 2024 14:20:49 +0530 Subject: [PATCH 02/25] Delete permit-graph.html --- permit-graph.html | 238 ---------------------------------------------- 1 file changed, 238 deletions(-) delete mode 100644 permit-graph.html diff --git a/permit-graph.html b/permit-graph.html deleted file mode 100644 index d4c760e..0000000 --- a/permit-graph.html +++ /dev/null @@ -1,238 +0,0 @@ - - - - - - - ReBAC Graph - - - - - - -
Permit ReBAC Graph
-
- - - From 9f5532688f09404e5c7de3df53a5eefd48a4a15d Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Tue, 10 Dec 2024 21:45:55 +0530 Subject: [PATCH 03/25] prettiefy --- source/commands/graph.tsx | 22 +- source/components/HtmlGraphSaver.ts | 10 +- source/components/generateGraphData.ts | 128 ++++---- source/components/graphCommand.tsx | 417 +++++++++++++------------ source/lib/api.ts | 2 +- 5 files changed, 303 insertions(+), 276 deletions(-) diff --git a/source/commands/graph.tsx b/source/commands/graph.tsx index cdf6907..0b5a11b 100644 --- a/source/commands/graph.tsx +++ b/source/commands/graph.tsx @@ -5,19 +5,23 @@ import { type infer as zInfer, object, string } from 'zod'; import { option } from 'pastel'; export const options = object({ - apiKey: string() - .optional() - .describe(option({ description: 'The API key for the Permit env, project or Workspace' })), + apiKey: string() + .optional() + .describe( + option({ + description: 'The API key for the Permit env, project or Workspace', + }), + ), }); type Props = { - options: zInfer; + options: zInfer; }; export default function graph({ options }: Props) { - return ( - - - - ); + return ( + + + + ); } diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 07aeed3..8ad513c 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -3,8 +3,8 @@ import { resolve } from 'path'; import open from 'open'; export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { - const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); - const htmlTemplate = ` + const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); + const htmlTemplate = ` @@ -111,7 +111,7 @@ style: { `; - writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); - console.log(`Graph saved as: ${outputHTMLPath}`); - open(outputHTMLPath); + writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); + console.log(`Graph saved as: ${outputHTMLPath}`); + open(outputHTMLPath); }; diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index ebe1744..59841bc 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -1,81 +1,91 @@ // Define types type ResourceInstance = { - label: string; - value: string; - id: string; + label: string; + value: string; + id: string; }; type Relationship = { - label: string; - value: string; + label: string; + value: string; }; type RoleAssignment = { - user: string; - role: string; - resourceInstance: string; + user: string; + role: string; + resourceInstance: string; }; - // Generate Graph Data export const generateGraphData = ( - resources: ResourceInstance[], - relationships: Map, - roleAssignments: RoleAssignment[] + resources: ResourceInstance[], + relationships: Map, + roleAssignments: RoleAssignment[], ) => { - const nodes = resources.map((resource) => ({ - data: { id: resource.id, label: `Resource: ${resource.label}` }, - })); + const nodes = resources.map(resource => ({ + data: { id: resource.id, label: `Resource: ${resource.label}` }, + })); - const edges: { data: { source: string; target: string; label: string } }[] = []; - const existingNodeIds = new Set(nodes.map((node) => node.data.id)); + const edges: { data: { source: string; target: string; label: string } }[] = + []; + const existingNodeIds = new Set(nodes.map(node => node.data.id)); - relationships.forEach((relations, resourceId) => { - relations.forEach((relation) => { - if (!existingNodeIds.has(relation.value)) { - nodes.push({ data: { id: relation.value, label: `Resource: ${relation.value}` } }); - existingNodeIds.add(relation.value); - } + relationships.forEach((relations, resourceId) => { + relations.forEach(relation => { + if (!existingNodeIds.has(relation.value)) { + nodes.push({ + data: { id: relation.value, label: `Resource: ${relation.value}` }, + }); + existingNodeIds.add(relation.value); + } - edges.push({ - data: { source: resourceId, target: relation.value, label: relation.label }, - }); - }); - }); + edges.push({ + data: { + source: resourceId, + target: relation.value, + label: relation.label, + }, + }); + }); + }); - // Add role assignments to the graph - roleAssignments.forEach((assignment) => { - // Add user nodes - if (!existingNodeIds.has(assignment.user)) { - nodes.push({ data: { id: assignment.user, label: `User: ${assignment.user}` } }); - existingNodeIds.add(assignment.user); - } + // Add role assignments to the graph + roleAssignments.forEach(assignment => { + // Add user nodes + if (!existingNodeIds.has(assignment.user)) { + nodes.push({ + data: { id: assignment.user, label: `User: ${assignment.user}` }, + }); + existingNodeIds.add(assignment.user); + } - // Add role nodes - const roleNodeId = `role:${assignment.role}`; - if (!existingNodeIds.has(roleNodeId)) { - nodes.push({ data: { id: roleNodeId, label: `Role: ${assignment.role}` } }); - existingNodeIds.add(roleNodeId); - } + // Add role nodes + const roleNodeId = `role:${assignment.role}`; + if (!existingNodeIds.has(roleNodeId)) { + nodes.push({ + data: { id: roleNodeId, label: `Role: ${assignment.role}` }, + }); + existingNodeIds.add(roleNodeId); + } - // Connect user to role - edges.push({ - data: { - source: assignment.user, - target: roleNodeId, - label: `Assigned role`, - }, - }); + // Connect user to role + edges.push({ + data: { + source: assignment.user, + target: roleNodeId, + label: `Assigned role`, + }, + }); - // Connect role to resource instance - edges.push({ - data: { - source: roleNodeId, - target: assignment.resourceInstance, - label: `Grants access`, - }, - }); - }); + // Connect role to resource instance + edges.push({ + data: { + source: roleNodeId, + target: assignment.resourceInstance, + label: `Grants access`, + }, + }); + }); - return { nodes, edges }; + return { nodes, edges }; }; diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index b992b1a..89d8626 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -11,218 +11,231 @@ import { useAuth } from '../components/AuthProvider.js'; // Import useAuth // Define types type Relationship = { - label: string; - value: string; + label: string; + value: string; }; type RoleAssignment = { - user: string; - role: string; - resourceInstance: string; + user: string; + role: string; + resourceInstance: string; }; export const options = zod.object({ - apiKey: zod - .string() - .optional() - .describe( - option({ - description: 'The API key for the Permit env, project or Workspace', - }), - ), + apiKey: zod + .string() + .optional() + .describe( + option({ + description: 'The API key for the Permit env, project or Workspace', + }), + ), }); type Props = { - options: zod.infer; + options: zod.infer; }; export default function Graph({ options }: Props) { - const { authToken: contextAuthToken, loading: authLoading, error: authError } = useAuth(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [authToken, setAuthToken] = useState(null); // Store resolved authToken - const [state, setState] = useState<'project' | 'environment' | 'graph'>('project'); - const [projects, setProjects] = useState<[]>([]); - const [environments, setEnvironments] = useState<[]>([]); - const [selectedProject, setSelectedProject] = useState(null); - const [selectedEnvironment, setSelectedEnvironment] = useState(null); - - // Resolve the authToken on mount - useEffect(() => { - const token = contextAuthToken || options.apiKey || null; - if (!token) { - setError('No auth token found. Please log in or provide an API key.'); - } else { - setAuthToken(token); - } - }, [contextAuthToken, options.apiKey]); - - // Fetch projects - useEffect(() => { - const fetchProjects = async () => { - if (!authToken) return; - - try { - setLoading(true); - const { response: projects } = await apiCall('v2/projects', authToken); - setProjects( - projects['map']((project: any) => ({ - label: project.name, - value: project.id, - })) - ); - setLoading(false); - } catch (err) { - console.error('Error fetching projects:', err); - setError('Failed to fetch projects.'); - setLoading(false); - } - }; - - if (state === 'project') { - fetchProjects(); - } - }, [state, authToken]); - - // Fetch environments - useEffect(() => { - const fetchEnvironments = async () => { - if (!authToken || !selectedProject) return; - - try { - setLoading(true); - const { response: environments } = await apiCall( - `v2/projects/${selectedProject.value}/envs`, - authToken - ); - setEnvironments( - environments['map']((env: any) => ({ - label: env.name, - value: env.id, - })) - ); - setLoading(false); - } catch (err) { - console.error('Error fetching environments:', err); - setError('Failed to fetch environments.'); - setLoading(false); - } - }; - - if (state === 'environment') { - fetchEnvironments(); - } - }, [state, authToken, selectedProject]); - - // Fetch graph data - useEffect(() => { - const fetchData = async () => { - if (!authToken || !selectedProject || !selectedEnvironment) return; - - try { - setLoading(true); - - const resourceResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true`, - authToken - ); - - const resourcesData = resourceResponse.response['map']((res: any) => ({ - label: res.resource, - value: res.id, - id: res.id, - })); - - const relationsMap = new Map(); - resourceResponse.response['forEach']((resource: any) => { - const relationsData = resource.relationships || []; - relationsMap.set( - resource.id, - relationsData.map((relation: any) => ({ - label: `${relation.relation} → ${relation.object}`, - value: relation.object || 'Unknown ID', - })) - ); - }); - - const roleAssignmentsData: RoleAssignment[] = []; - for (const resource of resourcesData) { - const roleResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/role_assignments?resource_instance=${resource.id}`, - authToken - ); - - roleAssignmentsData.push( - ...roleResponse.response['map']((role: any) => ({ - user: role.user || 'Unknown User', - role: role.role || 'Unknown Role', - resourceInstance: resource.id, - })) - ); - } - - const graphData = generateGraphData(resourcesData, relationsMap, roleAssignmentsData); - saveHTMLGraph(graphData); - setLoading(false); - } catch (err) { - console.error('Error fetching graph data:', err); - setError('Failed to fetch data. Check network or auth token.'); - setLoading(false); - } - }; - - if (state === 'graph') { - fetchData(); - } - }, [state, authToken, selectedProject, selectedEnvironment]); - - // Loading and error states - if (authLoading || loading) { - return ( - - {authLoading ? 'Authenticating...' : 'Loading Permit Graph...'} - - ); - } - - if (authError || error) { - return {authError || error}; - } - - // State rendering - if (state === 'project' && projects.length > 0) { - return ( - <> - Select a project - { - setSelectedProject(project); - setState('environment'); - }} - /> - - ); - } - - if (state === 'environment' && environments.length > 0) { - return ( - <> - Select an environment - { - setSelectedEnvironment(environment); - setState('graph'); - }} - /> - - ); - } - - if (state === 'graph') { - return Graph generated successfully and saved as HTML!; - } - - return Initializing...; + const { + authToken: contextAuthToken, + loading: authLoading, + error: authError, + } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [authToken, setAuthToken] = useState(null); // Store resolved authToken + const [state, setState] = useState<'project' | 'environment' | 'graph'>( + 'project', + ); + const [projects, setProjects] = useState<[]>([]); + const [environments, setEnvironments] = useState<[]>([]); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedEnvironment, setSelectedEnvironment] = useState( + null, + ); + + // Resolve the authToken on mount + useEffect(() => { + const token = contextAuthToken || options.apiKey || null; + if (!token) { + setError('No auth token found. Please log in or provide an API key.'); + } else { + setAuthToken(token); + } + }, [contextAuthToken, options.apiKey]); + + // Fetch projects + useEffect(() => { + const fetchProjects = async () => { + if (!authToken) return; + + try { + setLoading(true); + const { response: projects } = await apiCall('v2/projects', authToken); + setProjects( + projects['map']((project: any) => ({ + label: project.name, + value: project.id, + })), + ); + setLoading(false); + } catch (err) { + console.error('Error fetching projects:', err); + setError('Failed to fetch projects.'); + setLoading(false); + } + }; + + if (state === 'project') { + fetchProjects(); + } + }, [state, authToken]); + + // Fetch environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!authToken || !selectedProject) return; + + try { + setLoading(true); + const { response: environments } = await apiCall( + `v2/projects/${selectedProject.value}/envs`, + authToken, + ); + setEnvironments( + environments['map']((env: any) => ({ + label: env.name, + value: env.id, + })), + ); + setLoading(false); + } catch (err) { + console.error('Error fetching environments:', err); + setError('Failed to fetch environments.'); + setLoading(false); + } + }; + + if (state === 'environment') { + fetchEnvironments(); + } + }, [state, authToken, selectedProject]); + + // Fetch graph data + useEffect(() => { + const fetchData = async () => { + if (!authToken || !selectedProject || !selectedEnvironment) return; + + try { + setLoading(true); + + const resourceResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true`, + authToken, + ); + + const resourcesData = resourceResponse.response['map']((res: any) => ({ + label: res.resource, + value: res.id, + id: res.id, + })); + + const relationsMap = new Map(); + resourceResponse.response['forEach']((resource: any) => { + const relationsData = resource.relationships || []; + relationsMap.set( + resource.id, + relationsData.map((relation: any) => ({ + label: `${relation.relation} → ${relation.object}`, + value: relation.object || 'Unknown ID', + })), + ); + }); + + const roleAssignmentsData: RoleAssignment[] = []; + for (const resource of resourcesData) { + const roleResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/role_assignments?resource_instance=${resource.id}`, + authToken, + ); + + roleAssignmentsData.push( + ...roleResponse.response['map']((role: any) => ({ + user: role.user || 'Unknown User', + role: role.role || 'Unknown Role', + resourceInstance: resource.id, + })), + ); + } + + const graphData = generateGraphData( + resourcesData, + relationsMap, + roleAssignmentsData, + ); + saveHTMLGraph(graphData); + setLoading(false); + } catch (err) { + console.error('Error fetching graph data:', err); + setError('Failed to fetch data. Check network or auth token.'); + setLoading(false); + } + }; + + if (state === 'graph') { + fetchData(); + } + }, [state, authToken, selectedProject, selectedEnvironment]); + + // Loading and error states + if (authLoading || loading) { + return ( + + {' '} + {authLoading ? 'Authenticating...' : 'Loading Permit Graph...'} + + ); + } + + if (authError || error) { + return {authError || error}; + } + + // State rendering + if (state === 'project' && projects.length > 0) { + return ( + <> + Select a project + { + setSelectedProject(project); + setState('environment'); + }} + /> + + ); + } + + if (state === 'environment' && environments.length > 0) { + return ( + <> + Select an environment + { + setSelectedEnvironment(environment); + setState('graph'); + }} + /> + + ); + } + + if (state === 'graph') { + return Graph generated successfully and saved as HTML!; + } + + return Initializing...; } diff --git a/source/lib/api.ts b/source/lib/api.ts index da90c4c..a82e198 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,7 +1,7 @@ import { PERMIT_API_URL } from '../config.js'; interface ApiResponseData { - [x: string]: any; + [x: string]: any; id?: string; name?: string; } From 5e1feb9a748def46a3d8153f0a3de96837d2e4a8 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Fri, 27 Dec 2024 20:37:18 +0530 Subject: [PATCH 04/25] fixed styling plus fonts --- source/components/HtmlGraphSaver.ts | 172 ++++++++++++++----------- source/components/generateGraphData.ts | 14 +- source/components/graphCommand.tsx | 23 +++- 3 files changed, 123 insertions(+), 86 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 8ad513c..2f5c357 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -14,34 +14,52 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { + + @@ -54,63 +72,67 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { const cy = cytoscape({ container: document.getElementById('cy'), elements: [...graphData.nodes, ...graphData.edges], - style: [ - { - selector: 'edge', -style: { - 'line-color': '#00ffff', - 'width': 2, - 'target-arrow-shape': 'triangle', - 'target-arrow-color': '#00ffff', - 'curve-style': 'taxi', // Correct taxi style - 'taxi-turn': 30, // Adjust turn distance for better visualization - 'taxi-direction': 'downward', // Controls edge direction - 'taxi-turn-min-distance': 20, // Ensures proper separation for multiple edges - 'label': 'data(label)', // Add labels properly - 'color': '#ffffff', - 'font-size': 12, - 'text-background-color': '#1e1e1e', - 'text-background-opacity': 0.7, - 'text-margin-y': -5, -}, - -}, - - { - selector: 'node', - style: { - 'background-color': '#2b2b2b', - 'border-color': '#00ffff', - 'border-width': 1, - 'shape': 'round-rectangle', - 'label': 'data(label)', - 'color': '#ffffff', - 'font-size': 14, - 'text-valign': 'center', - 'text-halign': 'center', - 'width': 'label', - 'height': 'label', - 'padding': 30, - }, - }, -], + style: [ + { + selector: 'edge', + style: { + 'line-color': '#ED5F00', + 'width': 5, + 'target-arrow-shape': 'triangle', + 'target-arrow-color': '#441F04', + 'curve-style': 'taxi', + 'taxi-turn': 30, + 'taxi-direction': 'downward', + 'taxi-turn-min-distance': 20, + 'label': 'data(label)', + 'color': '#ffffff', + 'font-size': 25, + 'font-family': 'Manrope, Arial, sans-serif', + 'font-weight': 500, /* Adjusted for edge labels */ + 'text-background-color': '#1e1e1e', + 'text-background-opacity': 0.8, + 'text-background-padding': 8, + 'text-margin-y': -25, + }, + }, + { + selector: 'node', + style: { + 'background-color': 'rgb(43, 20, 0)', + 'border-color': '#ffffff', + 'border-width': 8, + 'shape': 'round-rectangle', + 'label': 'data(label)', + 'color': 'hsl(0, 0%, 100%)', + 'font-size': 30, + 'font-family': 'Manrope, Arial, sans-serif', + 'font-weight': 700, /* Adjusted for node labels */ + 'text-valign': 'center', + 'text-halign': 'center', + 'width': 'label', + 'height': 'label', + 'padding': 45, + }, + }, + ], layout: { - name: 'dagre', - rankDir: 'LR', // Left-to-Right layout - nodeSep: 70, // Spacing between nodes - edgeSep: 50, // Spacing between edges - rankSep: 150, // Spacing between ranks (hierarchical levels) - animate: true, // Animate the layout rendering - fit: true, // Fit graph to the viewport - padding: 20, // Padding around the graph - directed: true, // Keep edges directed - spacingFactor: 1.5, // Increase spacing between elements -}, + name: 'dagre', + rankDir: 'LR', + nodeSep: 70, + edgeSep: 50, + rankSep: 150, + animate: true, + fit: true, + padding: 20, + directed: true, + spacingFactor: 1.5, + }, }); `; + writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); console.log(`Graph saved as: ${outputHTMLPath}`); open(outputHTMLPath); diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 59841bc..573187d 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -23,7 +23,7 @@ export const generateGraphData = ( roleAssignments: RoleAssignment[], ) => { const nodes = resources.map(resource => ({ - data: { id: resource.id, label: `Resource: ${resource.label}` }, + data: { id: resource.id, label: ` ${resource.label}` }, })); const edges: { data: { source: string; target: string; label: string } }[] = @@ -34,7 +34,7 @@ export const generateGraphData = ( relations.forEach(relation => { if (!existingNodeIds.has(relation.value)) { nodes.push({ - data: { id: relation.value, label: `Resource: ${relation.value}` }, + data: { id: relation.value, label: `${relation.value}` }, }); existingNodeIds.add(relation.value); } @@ -54,16 +54,16 @@ export const generateGraphData = ( // Add user nodes if (!existingNodeIds.has(assignment.user)) { nodes.push({ - data: { id: assignment.user, label: `User: ${assignment.user}` }, + data: { id: assignment.user, label: `${assignment.user}` }, }); existingNodeIds.add(assignment.user); } // Add role nodes - const roleNodeId = `role:${assignment.role}`; + const roleNodeId = `${assignment.role}`; if (!existingNodeIds.has(roleNodeId)) { nodes.push({ - data: { id: roleNodeId, label: `Role: ${assignment.role}` }, + data: { id: roleNodeId, label: ` ${assignment.role}` }, }); existingNodeIds.add(roleNodeId); } @@ -73,7 +73,7 @@ export const generateGraphData = ( data: { source: assignment.user, target: roleNodeId, - label: `Assigned role`, + label: `Assigned Role`, }, }); @@ -82,7 +82,7 @@ export const generateGraphData = ( data: { source: roleNodeId, target: assignment.resourceInstance, - label: `Grants access`, + label: `Grants Access`, }, }); }); diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 89d8626..0216570 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -142,14 +142,29 @@ export default function Graph({ options }: Props) { })); const relationsMap = new Map(); + resourceResponse.response['forEach']((resource: any) => { const relationsData = resource.relationships || []; relationsMap.set( resource.id, - relationsData.map((relation: any) => ({ - label: `${relation.relation} → ${relation.object}`, - value: relation.object || 'Unknown ID', - })), + relationsData.map((relation: any) => { + // Ensure relation.object follows the "resource#role" format + let formattedObject = relation.object || 'Unknown ID'; + if (formattedObject.includes(':')) { + const [resourcePart, rolePart] = formattedObject.split(':'); + formattedObject = `${resourcePart}#${rolePart}`; + } + + // Convert relation.relation to uppercase + const relationLabel = relation.relation + ? relation.relation.toUpperCase() + : 'UNKNOWN RELATION'; + + return { + label: `IS ${relationLabel} OF`, + value: formattedObject, + }; + }), ); }); From 2b0246fd5e2a4e24d8abd5329bf0349106e61a34 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 6 Jan 2025 18:08:38 +0530 Subject: [PATCH 05/25] updates styles and themes --- source/components/HtmlGraphSaver.ts | 39 +++++++++++------------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 2f5c357..4277032 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -28,7 +28,7 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { } #title { - text-align: center; + text-align: center; font-size: 30px; font-weight: 600; height: 50px; @@ -41,24 +41,13 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { } #cy { - flex: 1; - width: 100%; - background-color: #FFF1E7; - padding: 10px; - } - - #cy::before { - content: "NEVER BUILD PERMISSIONS AGAIN"; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 50px; - font-weight: 600; - color: rgba(43, 20, 0, 0.15); /* Subtle background text color */ - pointer-events: none; /* Ensure this text doesn't block interactions */ - text-align: center; - font-family: 'Manrope', Arial, sans-serif; + flex: 1; + width: 100%; + background-color: #FFF1E7; /* Base background color */ + padding: 10px; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAj0lEQVR4Ae3YMQoEIRBE0Ro1NhHvfz8xMhXc3RnYGyjFwH8n6E931NfnRy8W9HIEuBHgRoAbAW4EuBHgRoAbAW4EuBHglrTZGEOtNcUYVUpRzlknbd9A711rLc05n5DTtgfcw//dWzhte0CtVSklhRCeEzrt4jNnRoAbAW4EuBHgRoAbAW4EuBHgRoAbAW5fFH4dU6tFNJ4AAAAASUVORK5CYII="); + background-size: 30px 35px; /* Matches the original image dimensions */ + background-repeat: repeat; /* Ensures the pattern repeats */ } @@ -76,10 +65,10 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { { selector: 'edge', style: { - 'line-color': '#ED5F00', + 'line-color': 'rgb(18, 165, 148)', 'width': 5, 'target-arrow-shape': 'triangle', - 'target-arrow-color': '#441F04', + 'target-arrow-color': 'rgb(18, 165, 148)', 'curve-style': 'taxi', 'taxi-turn': 30, 'taxi-direction': 'downward', @@ -89,7 +78,7 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'font-size': 25, 'font-family': 'Manrope, Arial, sans-serif', 'font-weight': 500, /* Adjusted for edge labels */ - 'text-background-color': '#1e1e1e', + 'text-background-color': 'rgb(18, 165, 148)', 'text-background-opacity': 0.8, 'text-background-padding': 8, 'text-margin-y': -25, @@ -98,12 +87,12 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { { selector: 'node', style: { - 'background-color': 'rgb(43, 20, 0)', - 'border-color': '#ffffff', + 'background-color': 'rgb(255, 255, 255)', + 'border-color': 'rgb(211, 179, 250)', 'border-width': 8, 'shape': 'round-rectangle', 'label': 'data(label)', - 'color': 'hsl(0, 0%, 100%)', + 'color': 'rgb(151, 78, 242)', 'font-size': 30, 'font-family': 'Manrope, Arial, sans-serif', 'font-weight': 700, /* Adjusted for node labels */ From 1f50d3fb0bd7d1b9f11ca4a90267f5ce0977877b Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 6 Jan 2025 20:32:34 +0530 Subject: [PATCH 06/25] resolved conflit --- source/lib/api.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/source/lib/api.ts b/source/lib/api.ts index 4d79109..25fa2c4 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,12 +1,6 @@ import { PERMIT_API_URL } from '../config.js'; -interface ApiResponseData { - [x: string]: any; - id?: string; - name?: string; -} - -type ApiResponse = { +type ApiResponse = { headers: Headers; response: T; status: number; @@ -61,4 +55,4 @@ export const apiCall = async ( error instanceof Error ? error.message : 'Unknown fetch error occurred'; } return defaultResponse; -}; +}; \ No newline at end of file From b38552484e9f1a96d0bda4f39e1895f318d41964 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 6 Jan 2025 20:40:57 +0530 Subject: [PATCH 07/25] centered edge labels . --- source/components/HtmlGraphSaver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 4277032..48d64cd 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -81,7 +81,7 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'text-background-color': 'rgb(18, 165, 148)', 'text-background-opacity': 0.8, 'text-background-padding': 8, - 'text-margin-y': -25, + 'text-margin-y': 0, }, }, { From d32d4919ea50ddfe2fe2e3e79fb7896a13d7f34e Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Tue, 7 Jan 2025 12:26:19 +0530 Subject: [PATCH 08/25] fixed functionalities 1 Users is now in orange boxes 2 Resource instance in purple 3 Resource instances is in the following naming convention resource_type#resource_id 4. Green lines is presenting the roles and the connection between a user and resource instance (the pill shows the role assigned) 5 Green dashed lines added it show the implicit (derived) assignment of a user to a resource 6 Orange lines now presents relationships between resource instances (the pill describes the relationship) --- source/components/HtmlGraphSaver.ts | 31 +++++++++++++ source/components/generateGraphData.ts | 61 ++++++++++++++++---------- source/components/graphCommand.tsx | 2 +- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 48d64cd..6739efe 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -67,6 +67,7 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { style: { 'line-color': 'rgb(18, 165, 148)', 'width': 5, + 'shape': 'round-rectangle', 'target-arrow-shape': 'triangle', 'target-arrow-color': 'rgb(18, 165, 148)', 'curve-style': 'taxi', @@ -83,6 +84,21 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'text-background-padding': 8, 'text-margin-y': 0, }, + },{ + selector: 'edge.relationship-connection', + style: { + 'line-color': '#F76808', + 'target-arrow-color': '#F76808', + 'text-background-color': '#F76808', + } + },{ + selector: 'edge.implicit-role-connection', + style: { + 'line-color': 'rgb(18, 165, 148)', + 'line-style': 'dashed', + 'target-arrow-color': 'rgb(18, 165, 148)', + 'text-background-color': 'rgb(18, 165, 148)', + } }, { selector: 'node', @@ -102,6 +118,21 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'height': 'label', 'padding': 45, }, + },{ + selector: 'node.user-node', + style: { + 'border-color': '#FFB381', /*light Orange border */ + 'color': '#F76808', /* Orange text */ + + }, + }, + { + selector: 'node.resource-instance-node', + style: { + 'border-color': '#D3B3FA', /* light Purple border */ + 'color': '#974EF2', /* Purple text */ + + }, }, ], layout: { diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 573187d..af30fee 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -22,12 +22,16 @@ export const generateGraphData = ( relationships: Map, roleAssignments: RoleAssignment[], ) => { - const nodes = resources.map(resource => ({ - data: { id: resource.id, label: ` ${resource.label}` }, - })); + const nodes: { data: { id: string; label: string }; classes?: string }[] = + resources.map(resource => ({ + data: { id: resource.id, label: ` ${resource.label}` }, + classes: 'resource-instance-node', + })); - const edges: { data: { source: string; target: string; label: string } }[] = - []; + const edges: { + data: { source: string; target: string; label: string }; + classes?: string; + }[] = []; const existingNodeIds = new Set(nodes.map(node => node.data.id)); relationships.forEach((relations, resourceId) => { @@ -45,47 +49,58 @@ export const generateGraphData = ( target: relation.value, label: relation.label, }, + classes: 'relationship-connection', // Class for orange lines }); }); }); - // Add role assignments to the graph + // add role assignments to the graph roleAssignments.forEach(assignment => { - // Add user nodes + // Add user nodes with a specific class if (!existingNodeIds.has(assignment.user)) { nodes.push({ data: { id: assignment.user, label: `${assignment.user}` }, + classes: 'user-node', }); existingNodeIds.add(assignment.user); } - // Add role nodes - const roleNodeId = `${assignment.role}`; - if (!existingNodeIds.has(roleNodeId)) { - nodes.push({ - data: { id: roleNodeId, label: ` ${assignment.role}` }, - }); - existingNodeIds.add(roleNodeId); - } - - // Connect user to role + // Connect user to resource instance edges.push({ data: { source: assignment.user, - target: roleNodeId, - label: `Assigned Role`, + target: assignment.resourceInstance, + label: `${assignment.role}`, }, }); + }); - // Connect role to resource instance + // Infer implicit assignments and add them as dashed green lines + const implicitAssignments: { user: string; resourceInstance: string }[] = []; + relationships.forEach((relations, sourceId) => { + const directlyAssignedUsers = roleAssignments + .filter(assignment => assignment.resourceInstance === sourceId) + .map(assignment => assignment.user); + + relations.forEach(relation => { + directlyAssignedUsers.forEach(user => { + implicitAssignments.push({ + user, // The user indirectly assigned + resourceInstance: relation.value, // Target resource instance + }); + }); + }); + }); + + implicitAssignments.forEach(assignment => { edges.push({ data: { - source: roleNodeId, + source: assignment.user, target: assignment.resourceInstance, - label: `Grants Access`, + label: 'DERIVES', // Label for dashed green lines }, + classes: 'implicit-role-connection', // Class for styling dashed green lines }); }); - return { nodes, edges }; }; diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 0216570..fe48792 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -136,7 +136,7 @@ export default function Graph({ options }: Props) { ); const resourcesData = resourceResponse.response['map']((res: any) => ({ - label: res.resource, + label: `${res.resource}#${res.resource_id}`, value: res.id, id: res.id, })); From 335d7a7a90e9891183be153489cfe3fbb8bb6a17 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Sat, 11 Jan 2025 17:55:11 +0530 Subject: [PATCH 09/25] fixed more functionalities issues added more detailed styling Orange lines now presenting relationships between resource instances (the pill is describing the relationship) every user is now displayed in the graph lines now use better styling --- source/components/HtmlGraphSaver.ts | 20 ++---- source/components/generateGraphData.ts | 84 +++++++++++++------------ source/components/graphCommand.tsx | 87 +++++++++++++++++++------- 3 files changed, 115 insertions(+), 76 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 6739efe..bcd587d 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -71,9 +71,9 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'target-arrow-shape': 'triangle', 'target-arrow-color': 'rgb(18, 165, 148)', 'curve-style': 'taxi', - 'taxi-turn': 30, - 'taxi-direction': 'downward', - 'taxi-turn-min-distance': 20, + 'taxi-turn': '45%', + 'taxi-direction': 'vertical', + 'taxi-turn-min-distance': 5, 'label': 'data(label)', 'color': '#ffffff', 'font-size': 25, @@ -91,14 +91,6 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { 'target-arrow-color': '#F76808', 'text-background-color': '#F76808', } - },{ - selector: 'edge.implicit-role-connection', - style: { - 'line-color': 'rgb(18, 165, 148)', - 'line-style': 'dashed', - 'target-arrow-color': 'rgb(18, 165, 148)', - 'text-background-color': 'rgb(18, 165, 148)', - } }, { selector: 'node', @@ -138,9 +130,9 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { layout: { name: 'dagre', rankDir: 'LR', - nodeSep: 70, - edgeSep: 50, - rankSep: 150, + nodeSep: 250, + edgeSep: 300, + rankSep: 350, animate: true, fit: true, padding: 20, diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index af30fee..c4dac18 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -8,6 +8,9 @@ type ResourceInstance = { type Relationship = { label: string; value: string; + id: string; + subjectvalue: string; + value1: string; }; type RoleAssignment = { @@ -36,21 +39,44 @@ export const generateGraphData = ( relationships.forEach((relations, resourceId) => { relations.forEach(relation => { - if (!existingNodeIds.has(relation.value)) { + if (!existingNodeIds.has(relation.value1)) { + nodes.push({ + data: { id: relation.value1, label: `${relation.value1}` }, + }); + existingNodeIds.add(relation.value1); + } + + if (resourceId !== relation.value1) { + edges.push({ + data: { + source: resourceId, + target: relation.value1, + label: `IS ${relation.label} OF`, + }, + classes: 'relationship-connection', // Class for orange lines + }); + } + }); + }); + relationships.forEach((relations, subjectvalue) => { + relations.forEach(relation => { + if (!existingNodeIds.has(relation.id)) { nodes.push({ data: { id: relation.value, label: `${relation.value}` }, }); existingNodeIds.add(relation.value); } - edges.push({ - data: { - source: resourceId, - target: relation.value, - label: relation.label, - }, - classes: 'relationship-connection', // Class for orange lines - }); + if (subjectvalue !== relation.value) { + edges.push({ + data: { + source: subjectvalue, + target: relation.value, + label: relation.label, + }, + classes: 'relationship-connection', // Class for orange lines + }); + } }); }); @@ -66,41 +92,17 @@ export const generateGraphData = ( } // Connect user to resource instance - edges.push({ - data: { - source: assignment.user, - target: assignment.resourceInstance, - label: `${assignment.role}`, - }, - }); - }); - // Infer implicit assignments and add them as dashed green lines - const implicitAssignments: { user: string; resourceInstance: string }[] = []; - relationships.forEach((relations, sourceId) => { - const directlyAssignedUsers = roleAssignments - .filter(assignment => assignment.resourceInstance === sourceId) - .map(assignment => assignment.user); - - relations.forEach(relation => { - directlyAssignedUsers.forEach(user => { - implicitAssignments.push({ - user, // The user indirectly assigned - resourceInstance: relation.value, // Target resource instance - }); + if (assignment.role !== 'No Role Assigned') { + edges.push({ + data: { + source: assignment.user, + target: assignment.resourceInstance, + label: `${assignment.role}`, + }, }); - }); + } }); - implicitAssignments.forEach(assignment => { - edges.push({ - data: { - source: assignment.user, - target: assignment.resourceInstance, - label: 'DERIVES', // Label for dashed green lines - }, - classes: 'implicit-role-connection', // Class for styling dashed green lines - }); - }); return { nodes, edges }; }; diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index fe48792..bad929c 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -13,6 +13,9 @@ import { useAuth } from '../components/AuthProvider.js'; // Import useAuth type Relationship = { label: string; value: string; + id: string; + subjectvalue: string; + value1: string; }; type RoleAssignment = { @@ -139,8 +142,16 @@ export default function Graph({ options }: Props) { label: `${res.resource}#${res.resource_id}`, value: res.id, id: res.id, + id2: `${res.resource}:${res.key}`, + key: res.key, })); + // Create a lookup map for id2 to resource labels + const id2ToLabelMap = new Map(); + resourcesData.forEach((resource: { id2: string; id: string }) => { + id2ToLabelMap.set(resource.id2, resource.id); + }); + const relationsMap = new Map(); resourceResponse.response['forEach']((resource: any) => { @@ -148,12 +159,9 @@ export default function Graph({ options }: Props) { relationsMap.set( resource.id, relationsData.map((relation: any) => { - // Ensure relation.object follows the "resource#role" format - let formattedObject = relation.object || 'Unknown ID'; - if (formattedObject.includes(':')) { - const [resourcePart, rolePart] = formattedObject.split(':'); - formattedObject = `${resourcePart}#${rolePart}`; - } + // Check if relation.object matches any id2 + const matchedLabel = id2ToLabelMap.get(relation.object); + const matchedsubjectid = id2ToLabelMap.get(relation.subject); // Convert relation.relation to uppercase const relationLabel = relation.relation @@ -161,28 +169,65 @@ export default function Graph({ options }: Props) { : 'UNKNOWN RELATION'; return { - label: `IS ${relationLabel} OF`, - value: formattedObject, + label: relationLabel, + value: matchedLabel || relation.object, + value1: relation.object, + subjectvalue: matchedsubjectid || resource.id, + id: resource.id, }; }), ); }); const roleAssignmentsData: RoleAssignment[] = []; - for (const resource of resourcesData) { - const roleResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/role_assignments?resource_instance=${resource.id}`, - authToken, - ); - roleAssignmentsData.push( - ...roleResponse.response['map']((role: any) => ({ - user: role.user || 'Unknown User', - role: role.role || 'Unknown Role', - resourceInstance: resource.id, - })), - ); - } + const roleResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true`, + authToken, + ); + + const users = roleResponse.response?.data || []; + + users.forEach((user: any) => { + const usernames = user.first_name + ' ' + user.last_name; + + // Check if the user has associated tenants + if (user.associated_tenants?.length) { + user.associated_tenants.forEach((tenant: any) => { + if (tenant.resource_instance_roles?.length) { + tenant.resource_instance_roles.forEach( + (resourceInstanceRole: any) => { + const resourceInstanceId = + id2ToLabelMap.get( + `${resourceInstanceRole.resource}:${resourceInstanceRole.resource_instance}`, + ) || resourceInstanceRole.resource_instance; + + roleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: resourceInstanceRole.role || 'Unknown Role', + resourceInstance: + resourceInstanceId || 'Unknown Resource Instance', + }); + }, + ); + } else { + // Push default entry for users with no roles in the tenant + roleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); + } else { + // Push default entry for users with no associated tenants + roleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); const graphData = generateGraphData( resourcesData, From f38fbe263e331edce0d74600d73391765d919cfb Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 00:51:23 +0530 Subject: [PATCH 10/25] updated main logic for rendering graph labels added pagination support for better fetching added styles that render graph in configurable format --- source/components/HtmlGraphSaver.ts | 188 +++++++++++++++------------- source/components/graphCommand.tsx | 7 +- 2 files changed, 109 insertions(+), 86 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index bcd587d..cb231ae 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -58,91 +58,111 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { const graphData = ${JSON.stringify(graphData, null, 2)}; cytoscape.use(cytoscapeDagre); - const cy = cytoscape({ - container: document.getElementById('cy'), - elements: [...graphData.nodes, ...graphData.edges], - style: [ - { - selector: 'edge', - style: { - 'line-color': 'rgb(18, 165, 148)', - 'width': 5, - 'shape': 'round-rectangle', - 'target-arrow-shape': 'triangle', - 'target-arrow-color': 'rgb(18, 165, 148)', - 'curve-style': 'taxi', - 'taxi-turn': '45%', - 'taxi-direction': 'vertical', - 'taxi-turn-min-distance': 5, - 'label': 'data(label)', - 'color': '#ffffff', - 'font-size': 25, - 'font-family': 'Manrope, Arial, sans-serif', - 'font-weight': 500, /* Adjusted for edge labels */ - 'text-background-color': 'rgb(18, 165, 148)', - 'text-background-opacity': 0.8, - 'text-background-padding': 8, - 'text-margin-y': 0, - }, - },{ - selector: 'edge.relationship-connection', - style: { - 'line-color': '#F76808', - 'target-arrow-color': '#F76808', - 'text-background-color': '#F76808', - } - }, - { - selector: 'node', - style: { - 'background-color': 'rgb(255, 255, 255)', - 'border-color': 'rgb(211, 179, 250)', - 'border-width': 8, - 'shape': 'round-rectangle', - 'label': 'data(label)', - 'color': 'rgb(151, 78, 242)', - 'font-size': 30, - 'font-family': 'Manrope, Arial, sans-serif', - 'font-weight': 700, /* Adjusted for node labels */ - 'text-valign': 'center', - 'text-halign': 'center', - 'width': 'label', - 'height': 'label', - 'padding': 45, - }, - },{ - selector: 'node.user-node', - style: { - 'border-color': '#FFB381', /*light Orange border */ - 'color': '#F76808', /* Orange text */ - - }, - }, - { - selector: 'node.resource-instance-node', - style: { - 'border-color': '#D3B3FA', /* light Purple border */ - 'color': '#974EF2', /* Purple text */ - - }, - }, - ], - layout: { - name: 'dagre', - rankDir: 'LR', - nodeSep: 250, - edgeSep: 300, - rankSep: 350, - animate: true, - fit: true, - padding: 20, - directed: true, - spacingFactor: 1.5, - }, - }); - - + const cy = cytoscape({ + container: document.getElementById('cy'), + elements: [...graphData.nodes, ...graphData.edges], + style: [ + { + selector: 'edge', + style: { + 'line-color': 'rgb(18, 165, 148)', + width: 5, + shape: 'round-rectangle', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': 'rgb(18, 165, 148)', + 'curve-style': 'taxi', + 'taxi-turn': '45%', + 'taxi-direction': 'vertical', + 'taxi-turn-min-distance': 5, + 'target-label': 'data(label)', + color: '#ffffff', + 'font-size': 25, + 'font-family': 'Manrope, Arial, sans-serif', + 'font-weight': 500 /* Adjusted for edge labels */, + 'text-background-color': 'rgb(18, 165, 148)', + 'text-background-opacity': 0.8, + 'text-background-padding': 8, + 'text-rotation': 'autorotate', // Added for label rotation + 'text-margin-x': 20, // Added for positioning label closer to target node + 'target-distance-from-node': 46, + 'target-text-offset': 35, + }, + }, + { + selector: 'edge.relationship-connection', + style: { + 'line-color': '#F76808', + 'target-arrow-color': '#F76808', + 'text-background-color': '#F76808', + color: '#ffffff', + 'target-distance-from-node': 2, + 'target-text-offset': 13, + }, + }, + { + selector: 'node', + style: { + 'background-color': 'rgb(255, 255, 255)', + 'border-color': 'rgb(211, 179, 250)', + 'border-width': 8, + shape: 'round-rectangle', + label: 'data(label)', + color: 'rgb(151, 78, 242)', + 'font-size': 30, + 'font-family': 'Manrope, Arial, sans-serif', + 'font-weight': 700 /* Adjusted for node labels */, + 'text-valign': 'center', + 'text-halign': 'center', + width: 'label', + height: 'label', + padding: 45, + }, + }, + { + selector: 'node.user-node', + style: { + 'border-color': '#FFB381' /*light Orange border */, + color: '#F76808' /* Orange text */, + }, + }, + { + selector: 'node.resource-instance-node', + style: { + 'border-color': '#D3B3FA' /* light Purple border */, + color: '#974EF2' /* Purple text */, + }, + }, + ], + layout: { + name: 'dagre', + rankDir: 'TB', + nodeSep: 250, + edgeSep: 300, + rankSep: 350, + animate: true, + fit: true, + padding: 20, + directed: true, + spacingFactor: 1.5, + }, + }); + // Track target nodes and offsets dynamically + const targetOffsets = new Map(); // Keeps track of target nodes and their current offset + + cy.edges().forEach(edge => { + const target = edge.target().id(); // Get the target node ID + let offset = targetOffsets.get(target) || 13; // Default starting offset is 13 + + // Set the target-text-offset for the edge + edge.style('target-text-offset', offset); + + // Update the offset for the next edge targeting the same node + targetOffsets.set(target, offset + 45); // Increment by 22 + }); + + + `; writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index bad929c..8e1fd66 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -133,8 +133,11 @@ export default function Graph({ options }: Props) { try { setLoading(true); + const Page = 1; // Fetches the one page per page + const per_Page = 100; // api limit for 100 records per page + const resourceResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true`, + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true&page=${Page}&per_page=${per_Page}`, authToken, ); @@ -182,7 +185,7 @@ export default function Graph({ options }: Props) { const roleAssignmentsData: RoleAssignment[] = []; const roleResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true`, + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true&page=${Page}&per_page=${per_Page}`, authToken, ); From 96e417463f7de856f5bb1eb672c9c008cb177f7c Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 01:12:12 +0530 Subject: [PATCH 11/25] Ensure that for every target ID in the edges array there is a corresponding node --- source/components/generateGraphData.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index c4dac18..bcfab06 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -104,5 +104,19 @@ export const generateGraphData = ( } }); + // Ensure that for every target ID in the edges array there is a corresponding node + edges.forEach(edge => { + if (!existingNodeIds.has(edge.data.target)) { + nodes.push({ + data: { + id: edge.data.target, + label: `Node ${edge.data.target}`, + }, + classes: 'resource-instance-node', + }); + existingNodeIds.add(edge.data.target); + } + }); + return { nodes, edges }; }; From 189727e761fa5c95589175e67cf33594f2356dcf Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 02:07:07 +0530 Subject: [PATCH 12/25] added full pagination support --- source/components/graphCommand.tsx | 152 +++++++++++++++++------------ 1 file changed, 88 insertions(+), 64 deletions(-) diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 8e1fd66..3df43e1 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -133,31 +133,48 @@ export default function Graph({ options }: Props) { try { setLoading(true); - const Page = 1; // Fetches the one page per page - const per_Page = 100; // api limit for 100 records per page + const per_Page = 100; // API limit for 100 records per page + let Page = 1; + let hasMoreData = true; + let allResourcesData: { + label: string; + value: string; + id: string; + id2: string; + key: string; + }[] = []; + let allRoleAssignmentsData: RoleAssignment[] = []; + + while (hasMoreData) { + const resourceResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true&page=${Page}&per_page=${per_Page}`, + authToken, + ); - const resourceResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true&page=${Page}&per_page=${per_Page}`, - authToken, - ); + const resourcesData = resourceResponse.response.map((res: any) => ({ + label: `${res.resource}#${res.resource_id}`, + value: res.id, + id: res.id, + id2: `${res.resource}:${res.key}`, + key: res.key, + })); + + allResourcesData = [...allResourcesData, ...resourcesData]; - const resourcesData = resourceResponse.response['map']((res: any) => ({ - label: `${res.resource}#${res.resource_id}`, - value: res.id, - id: res.id, - id2: `${res.resource}:${res.key}`, - key: res.key, - })); + // Check if there are more pages to fetch + hasMoreData = resourceResponse.response.length === per_Page; + Page++; + } // Create a lookup map for id2 to resource labels const id2ToLabelMap = new Map(); - resourcesData.forEach((resource: { id2: string; id: string }) => { + allResourcesData.forEach((resource: { id2: string; id: string }) => { id2ToLabelMap.set(resource.id2, resource.id); }); const relationsMap = new Map(); - resourceResponse.response['forEach']((resource: any) => { + allResourcesData.forEach((resource: any) => { const relationsData = resource.relationships || []; relationsMap.set( resource.id, @@ -182,60 +199,67 @@ export default function Graph({ options }: Props) { ); }); - const roleAssignmentsData: RoleAssignment[] = []; + Page = 1; + hasMoreData = true; - const roleResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true&page=${Page}&per_page=${per_Page}`, - authToken, - ); + while (hasMoreData) { + const roleResponse = await apiCall( + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/users?include_resource_instance_roles=true&page=${Page}&per_page=${per_Page}`, + authToken, + ); - const users = roleResponse.response?.data || []; - - users.forEach((user: any) => { - const usernames = user.first_name + ' ' + user.last_name; - - // Check if the user has associated tenants - if (user.associated_tenants?.length) { - user.associated_tenants.forEach((tenant: any) => { - if (tenant.resource_instance_roles?.length) { - tenant.resource_instance_roles.forEach( - (resourceInstanceRole: any) => { - const resourceInstanceId = - id2ToLabelMap.get( - `${resourceInstanceRole.resource}:${resourceInstanceRole.resource_instance}`, - ) || resourceInstanceRole.resource_instance; - - roleAssignmentsData.push({ - user: usernames || 'Unknown User', - role: resourceInstanceRole.role || 'Unknown Role', - resourceInstance: - resourceInstanceId || 'Unknown Resource Instance', - }); - }, - ); - } else { - // Push default entry for users with no roles in the tenant - roleAssignmentsData.push({ - user: usernames || 'Unknown User', - role: 'No Role Assigned', - resourceInstance: 'No Resource Instance', - }); - } - }); - } else { - // Push default entry for users with no associated tenants - roleAssignmentsData.push({ - user: usernames || 'Unknown User', - role: 'No Role Assigned', - resourceInstance: 'No Resource Instance', - }); - } - }); + const users = roleResponse.response?.data || []; + + users.forEach((user: any) => { + const usernames = user.first_name + ' ' + user.last_name; + + // Check if the user has associated tenants + if (user.associated_tenants?.length) { + user.associated_tenants.forEach((tenant: any) => { + if (tenant.resource_instance_roles?.length) { + tenant.resource_instance_roles.forEach( + (resourceInstanceRole: any) => { + const resourceInstanceId = + id2ToLabelMap.get( + `${resourceInstanceRole.resource}:${resourceInstanceRole.resource_instance}`, + ) || resourceInstanceRole.resource_instance; + + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: resourceInstanceRole.role || 'Unknown Role', + resourceInstance: + resourceInstanceId || 'Unknown Resource Instance', + }); + }, + ); + } else { + // Push default entry for users with no roles in the tenant + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); + } else { + // Push default entry for users with no associated tenants + allRoleAssignmentsData.push({ + user: usernames || 'Unknown User', + role: 'No Role Assigned', + resourceInstance: 'No Resource Instance', + }); + } + }); + + // Check if there are more pages to fetch + hasMoreData = roleResponse.response.data.length === per_Page; + Page++; + } const graphData = generateGraphData( - resourcesData, + allResourcesData, relationsMap, - roleAssignmentsData, + allRoleAssignmentsData, ); saveHTMLGraph(graphData); setLoading(false); From 31e3680db7472fd411f29113fe0f8fa157bcbcd4 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 16:58:47 +0530 Subject: [PATCH 13/25] dynamic pagination support --- source/components/generateGraphData.ts | 28 +++++------ source/components/graphCommand.tsx | 66 ++++++++++++++------------ 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index bcfab06..9b70b5c 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -7,10 +7,10 @@ type ResourceInstance = { type Relationship = { label: string; - value: string; + ObjectId: string; id: string; - subjectvalue: string; - value1: string; + subjectId: string; + Object: string; }; type RoleAssignment = { @@ -39,18 +39,18 @@ export const generateGraphData = ( relationships.forEach((relations, resourceId) => { relations.forEach(relation => { - if (!existingNodeIds.has(relation.value1)) { + if (!existingNodeIds.has(relation.Object)) { nodes.push({ - data: { id: relation.value1, label: `${relation.value1}` }, + data: { id: relation.Object, label: `${relation.Object}` }, }); - existingNodeIds.add(relation.value1); + existingNodeIds.add(relation.Object); } - if (resourceId !== relation.value1) { + if (resourceId !== relation.Object) { edges.push({ data: { source: resourceId, - target: relation.value1, + target: relation.Object, label: `IS ${relation.label} OF`, }, classes: 'relationship-connection', // Class for orange lines @@ -58,20 +58,20 @@ export const generateGraphData = ( } }); }); - relationships.forEach((relations, subjectvalue) => { + relationships.forEach((relations, subjectId) => { relations.forEach(relation => { if (!existingNodeIds.has(relation.id)) { nodes.push({ - data: { id: relation.value, label: `${relation.value}` }, + data: { id: relation.ObjectId, label: `${relation.ObjectId}` }, }); - existingNodeIds.add(relation.value); + existingNodeIds.add(relation.ObjectId); } - if (subjectvalue !== relation.value) { + if (subjectId !== relation.ObjectId) { edges.push({ data: { - source: subjectvalue, - target: relation.value, + source: subjectId, + target: relation.ObjectId, label: relation.label, }, classes: 'relationship-connection', // Class for orange lines diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 3df43e1..3c52ff5 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -8,14 +8,15 @@ import { generateGraphData } from '../components/generateGraphData.js'; import zod from 'zod'; import { option } from 'pastel'; import { useAuth } from '../components/AuthProvider.js'; // Import useAuth +import { object } from 'joi'; // Define types type Relationship = { label: string; - value: string; id: string; - subjectvalue: string; - value1: string; + subjectId: string; + ObjectId: string; + Object: string; }; type RoleAssignment = { @@ -142,8 +143,10 @@ export default function Graph({ options }: Props) { id: string; id2: string; key: string; + relationships?: any[]; }[] = []; let allRoleAssignmentsData: RoleAssignment[] = []; + const relationsMap = new Map(); while (hasMoreData) { const resourceResponse = await apiCall( @@ -157,6 +160,7 @@ export default function Graph({ options }: Props) { id: res.id, id2: `${res.resource}:${res.key}`, key: res.key, + relationships: res.relationships || [], })); allResourcesData = [...allResourcesData, ...resourcesData]; @@ -166,39 +170,41 @@ export default function Graph({ options }: Props) { Page++; } + allResourcesData.forEach((resource: any) => { + console.log(resource.label); + }) + // Create a lookup map for id2 to resource labels const id2ToLabelMap = new Map(); allResourcesData.forEach((resource: { id2: string; id: string }) => { id2ToLabelMap.set(resource.id2, resource.id); }); - const relationsMap = new Map(); - - allResourcesData.forEach((resource: any) => { - const relationsData = resource.relationships || []; - relationsMap.set( - resource.id, - relationsData.map((relation: any) => { - // Check if relation.object matches any id2 - const matchedLabel = id2ToLabelMap.get(relation.object); - const matchedsubjectid = id2ToLabelMap.get(relation.subject); - - // Convert relation.relation to uppercase - const relationLabel = relation.relation - ? relation.relation.toUpperCase() - : 'UNKNOWN RELATION'; - - return { - label: relationLabel, - value: matchedLabel || relation.object, - value1: relation.object, - subjectvalue: matchedsubjectid || resource.id, - id: resource.id, - }; - }), - ); - }); - + allResourcesData.forEach((resource: any) => { + const relationsData = resource.relationships || []; + relationsMap.set( + resource.id, + relationsData.map((relation: any) => { + // Check if relation.object matches any id2 + const matchedLabel = id2ToLabelMap.get(relation.object); + const matchedsubjectid = id2ToLabelMap.get(relation.subject); + + // Convert relation.relation to uppercase + const relationLabel = relation.relation + ? relation.relation.toUpperCase() + : 'UNKNOWN RELATION'; + + return { + label: relationLabel, + objectId: matchedLabel || relation.object, + Object: relation.object, + subjectId: matchedsubjectid || resource.id, + id: resource.id, + }; + }), + ); + }); + Page = 1; hasMoreData = true; From 0babf0eaac143f9d170833f45f34a2f30956808a Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 17:40:56 +0530 Subject: [PATCH 14/25] removed console.logs --- source/components/graphCommand.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 3c52ff5..3e0a76d 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -8,7 +8,6 @@ import { generateGraphData } from '../components/generateGraphData.js'; import zod from 'zod'; import { option } from 'pastel'; import { useAuth } from '../components/AuthProvider.js'; // Import useAuth -import { object } from 'joi'; // Define types type Relationship = { @@ -170,10 +169,6 @@ export default function Graph({ options }: Props) { Page++; } - allResourcesData.forEach((resource: any) => { - console.log(resource.label); - }) - // Create a lookup map for id2 to resource labels const id2ToLabelMap = new Map(); allResourcesData.forEach((resource: { id2: string; id: string }) => { From 81fa83315bee76608e6c0bc80c0040b2445645e4 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 19:44:30 +0530 Subject: [PATCH 15/25] final updates --- source/components/generateGraphData.ts | 14 +++++++------- source/components/graphCommand.tsx | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 9b70b5c..855973a 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -7,7 +7,7 @@ type ResourceInstance = { type Relationship = { label: string; - ObjectId: string; + objectId: string; id: string; subjectId: string; Object: string; @@ -58,20 +58,20 @@ export const generateGraphData = ( } }); }); - relationships.forEach((relations, subjectId) => { + relationships.forEach((relations) => { relations.forEach(relation => { if (!existingNodeIds.has(relation.id)) { nodes.push({ - data: { id: relation.ObjectId, label: `${relation.ObjectId}` }, + data: { id: relation.objectId, label: `${relation.objectId}` }, }); - existingNodeIds.add(relation.ObjectId); + existingNodeIds.add(relation.objectId); } - if (subjectId !== relation.ObjectId) { + if (relation.subjectId !== relation.objectId) { edges.push({ data: { - source: subjectId, - target: relation.ObjectId, + source: relation.subjectId, + target: relation.objectId, label: relation.label, }, classes: 'relationship-connection', // Class for orange lines diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 3e0a76d..bfa2ea0 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -14,7 +14,7 @@ type Relationship = { label: string; id: string; subjectId: string; - ObjectId: string; + objectId: string; Object: string; }; @@ -193,7 +193,7 @@ export default function Graph({ options }: Props) { label: relationLabel, objectId: matchedLabel || relation.object, Object: relation.object, - subjectId: matchedsubjectid || resource.id, + subjectId: matchedsubjectid || relation.subject, id: resource.id, }; }), From dcfdeac5ae5011713c3d04a8454a7f1235f61243 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 20:05:36 +0530 Subject: [PATCH 16/25] prettiefy --- source/components/generateGraphData.ts | 2 +- source/components/graphCommand.tsx | 50 +++++++++++++------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 855973a..1622d85 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -58,7 +58,7 @@ export const generateGraphData = ( } }); }); - relationships.forEach((relations) => { + relationships.forEach(relations => { relations.forEach(relation => { if (!existingNodeIds.has(relation.id)) { nodes.push({ diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index bfa2ea0..256dd02 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -175,31 +175,31 @@ export default function Graph({ options }: Props) { id2ToLabelMap.set(resource.id2, resource.id); }); - allResourcesData.forEach((resource: any) => { - const relationsData = resource.relationships || []; - relationsMap.set( - resource.id, - relationsData.map((relation: any) => { - // Check if relation.object matches any id2 - const matchedLabel = id2ToLabelMap.get(relation.object); - const matchedsubjectid = id2ToLabelMap.get(relation.subject); - - // Convert relation.relation to uppercase - const relationLabel = relation.relation - ? relation.relation.toUpperCase() - : 'UNKNOWN RELATION'; - - return { - label: relationLabel, - objectId: matchedLabel || relation.object, - Object: relation.object, - subjectId: matchedsubjectid || relation.subject, - id: resource.id, - }; - }), - ); - }); - + allResourcesData.forEach((resource: any) => { + const relationsData = resource.relationships || []; + relationsMap.set( + resource.id, + relationsData.map((relation: any) => { + // Check if relation.object matches any id2 + const matchedLabel = id2ToLabelMap.get(relation.object); + const matchedsubjectid = id2ToLabelMap.get(relation.subject); + + // Convert relation.relation to uppercase + const relationLabel = relation.relation + ? relation.relation.toUpperCase() + : 'UNKNOWN RELATION'; + + return { + label: relationLabel, + objectId: matchedLabel || relation.object, + Object: relation.object, + subjectId: matchedsubjectid || relation.subject, + id: resource.id, + }; + }), + ); + }); + Page = 1; hasMoreData = true; From deb06cd354d37155264a9545f6a74174a7f266d5 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 13 Jan 2025 22:51:19 +0530 Subject: [PATCH 17/25] ensure every target has its node --- source/components/generateGraphData.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 1622d85..302420c 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -67,6 +67,13 @@ export const generateGraphData = ( existingNodeIds.add(relation.objectId); } + if (!existingNodeIds.has(relation.objectId)) { + nodes.push({ + data: { id: relation.objectId, label: `${relation.objectId}` }, + }); + existingNodeIds.add(relation.objectId); + } + if (relation.subjectId !== relation.objectId) { edges.push({ data: { @@ -91,6 +98,13 @@ export const generateGraphData = ( existingNodeIds.add(assignment.user); } + if (!existingNodeIds.has(assignment.resourceInstance)) { + nodes.push({ + data: { id: assignment.resourceInstance, label: `${assignment.resourceInstance}` }, + classes: 'resource-instance-node', + }); + existingNodeIds.add(assignment.resourceInstance); + } // Connect user to resource instance if (assignment.role !== 'No Role Assigned') { From 37a6696093e6c500692652161c905f82373c06e5 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Wed, 29 Jan 2025 21:18:27 +0530 Subject: [PATCH 18/25] updates regarding functionality and styling --- source/components/HtmlGraphSaver.ts | 268 ++++++++++++++++++------- source/components/generateGraphData.ts | 24 ++- source/components/graphCommand.tsx | 7 +- 3 files changed, 224 insertions(+), 75 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index cb231ae..88cc22a 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -5,58 +5,92 @@ import open from 'open'; export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); const htmlTemplate = ` - + - - - - ReBAC Graph - - - - - - + #title { + text-align: center; + font-size: 30px; + font-weight: 600; + height: 50px; + line-height: 50px; + background-clip: text; + -webkit-background-clip: text; + color: transparent; /* Makes the text color transparent to apply gradient */ + background-image: linear-gradient( + to right, + #ffba81, + #bb84ff + ); /* Gradient color */ + background-color: rgb( + 43, + 20, + 0 + ); /* Same background color as bg-[#2B1400] */ + } + + #cy { + flex: 1; + width: 100%; + background-color: linear-gradient( + 180deg, + #fff1e7 0%, + #ffe0d2 100% + ); /* Base background color */ + padding: 10px; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAj0lEQVR4Ae3YMQoEIRBE0Ro1NhHvfz8xMhXc3RnYGyjFwH8n6E931NfnRy8W9HIEuBHgRoAbAW4EuBHgRoAbAW4EuBHglrTZGEOtNcUYVUpRzlknbd9A711rLc05n5DTtgfcw//dWzhte0CtVSklhRCeEzrt4jNnRoAbAW4EuBHgRoAbAW4EuBHgRoAbAW5fFH4dU6tFNJ4AAAAASUVORK5CYII='); + background-size: 30px 35px; /* Matches the original image dimensions */ + background-repeat: repeat; /* Ensures the pattern repeats */ + } + + .popper-content { + background-color: rgb(255, 255, 255); + color: rgb(151, 78, 242); + border: 2px solid rgb(211, 179, 250); + padding: 10px; + border-radius: 5px; + font-family: 'Manrope', Arial, sans-serif; + font-size: 14px; + font-weight: 700; + max-width: 200px; + word-wrap: break-word; + display: none; /* Initially hidden */ + position: absolute; /* Positioning for the popper */ + z-index: 9999; /* Ensure it is on top */ + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2); + border-radius: 8px; + transition: opacity 0.2s ease-in-out; + letter-spacing: 0.5px; /* Works in standard HTML */ + } +
Permit ReBAC Graph
diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index 302420c..b5cdbc9 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -15,6 +15,7 @@ type Relationship = { type RoleAssignment = { user: string; + email: string; role: string; resourceInstance: string; }; @@ -92,18 +93,26 @@ export const generateGraphData = ( // Add user nodes with a specific class if (!existingNodeIds.has(assignment.user)) { nodes.push({ - data: { id: assignment.user, label: `${assignment.user}` }, + data: { + id: assignment.user, + label: `${assignment.user} ${assignment.email}`, + }, classes: 'user-node', }); existingNodeIds.add(assignment.user); } - if (!existingNodeIds.has(assignment.resourceInstance)) { - nodes.push({ - data: { id: assignment.resourceInstance, label: `${assignment.resourceInstance}` }, - classes: 'resource-instance-node', - }); - existingNodeIds.add(assignment.resourceInstance); + if (assignment.resourceInstance !== 'No Resource Instance') { + if (!existingNodeIds.has(assignment.resourceInstance)) { + nodes.push({ + data: { + id: assignment.resourceInstance, + label: `${assignment.resourceInstance}`, + }, + classes: 'resource-instance-node', + }); + existingNodeIds.add(assignment.resourceInstance); + } } // Connect user to resource instance @@ -114,6 +123,7 @@ export const generateGraphData = ( target: assignment.resourceInstance, label: `${assignment.role}`, }, + classes: 'user-edge', }); } }); diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index 256dd02..aa445b3 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -20,6 +20,7 @@ type Relationship = { type RoleAssignment = { user: string; + email: string; role: string; resourceInstance: string; }; @@ -212,7 +213,8 @@ export default function Graph({ options }: Props) { const users = roleResponse.response?.data || []; users.forEach((user: any) => { - const usernames = user.first_name + ' ' + user.last_name; + const usernames = user.key; + const email = user.email; // Check if the user has associated tenants if (user.associated_tenants?.length) { @@ -227,6 +229,7 @@ export default function Graph({ options }: Props) { allRoleAssignmentsData.push({ user: usernames || 'Unknown User', + email: email || '', role: resourceInstanceRole.role || 'Unknown Role', resourceInstance: resourceInstanceId || 'Unknown Resource Instance', @@ -237,6 +240,7 @@ export default function Graph({ options }: Props) { // Push default entry for users with no roles in the tenant allRoleAssignmentsData.push({ user: usernames || 'Unknown User', + email: email || '', role: 'No Role Assigned', resourceInstance: 'No Resource Instance', }); @@ -246,6 +250,7 @@ export default function Graph({ options }: Props) { // Push default entry for users with no associated tenants allRoleAssignmentsData.push({ user: usernames || 'Unknown User', + email: email || '', role: 'No Role Assigned', resourceInstance: 'No Resource Instance', }); From 20a3f17992cc594c61b473f4f409a536021e6de3 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Thu, 13 Feb 2025 12:53:15 +0530 Subject: [PATCH 19/25] changed graph library , using d3 for better control and ensuring consistency with our policy graph --- source/components/HtmlGraphSaver.ts | 736 +++++++++++++++++++--------- 1 file changed, 506 insertions(+), 230 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 88cc22a..3474d47 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -11,14 +11,12 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { ReBAC Graph - + + - - - - - -
Permit ReBAC Graph
-
- + `; writeFileSync(outputHTMLPath, htmlTemplate, 'utf8'); From 722e804c2dd7d9f2dce3043fdcef1005a398c525 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Mon, 17 Feb 2025 19:53:04 +0530 Subject: [PATCH 20/25] advance de algorithms has been added added support for layering ensuring every node is appear only once updates styles advance algorithms for rendering and calculating positions solved cross edges problem --- source/components/HtmlGraphSaver.ts | 822 +++++++++++++++++++------ source/components/generateGraphData.ts | 54 +- 2 files changed, 678 insertions(+), 198 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 3474d47..9303fec 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -50,20 +50,20 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { svg { width: 100vw; height: calc(100vh - 50px); - background: linear-gradient(180deg, #fff1e7 0%, #ffe0d2 100%); - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAj0lEQVR4Ae3YMQoEIRBE0Ro1NhHvfz8xMhXc3RnYGyjFwH8n6E931NfnRy8W9HIEuBHgRoAbAW4EuBHgRoAbAW4EuBHglrTZGEOtNcUYVUpRzlknbd9A711rLc05n5DTtgfcw//dWzhte0CtVSklhRCeEzrt4jNnRoAbAW4EuBHgRoAbAW4EuBHgRoAbAW5fFH4dU6tFNJ4AAAAASUVORK5CYII='); + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cdefs%3E%3Cpattern id='squarePattern' x='0' y='0' width='20' height='20' patternUnits='userSpaceOnUse'%3E%3Crect width='20' height='20' fill='white'/%3E%3Crect x='2' y='2' width='1.3' height='1.3' fill='%23ccc'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23squarePattern)'/%3E%3C/svg%3E") + repeat; } /* Main node style */ .node-main rect { - fill: #ffffff; - stroke: rgb(211, 179, 250); + fill: rgb(206, 231, 254); + stroke: rgb(206, 231, 254); stroke-width: 10px; - rx: 65px; - ry: 65px; + rx: 73px; + ry: 73px; } .node-main text { - fill: rgb(151, 78, 242); - font-size: 55px; + fill: rgb(0, 106, 220); + font-size: 85px; font-weight: 700; pointer-events: none; font-family: 'Manrope', Arial, sans-serif; @@ -73,14 +73,14 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { .node-text.user-node { /* Specific styling for text on user nodes */ - fill: #ff6600; /* for example */ + fill: rgb(67, 48, 43); /* for example */ dominant-baseline: middle; text-anchor: middle; } .node-text.resource-instance-node { /* Specific styling for text on resource instance nodes */ - fill: #7e23ec; + fill: rgb(0, 106, 220); dominant-baseline: middle; text-anchor: middle; } @@ -89,8 +89,8 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { USER NODE SPECIFIC STYLE ---------------------------------*/ .node-main.user-node rect { - fill: #fff0e7; - stroke: #fab587; + fill: rgb(234, 221, 215); + stroke: rgb(234, 221, 215); stroke-width: 15px; } @@ -98,34 +98,47 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { // RESOURCE INSTANCE NODE STYLE ---------------------------------*/ .node-main.resource-instance-node rect { - fill: #f4eefc; - stroke: #caa5f7; + fill: rgb(206, 231, 254); + stroke: rgb(206, 231, 254); } /* Port node style */ .node-port { - fill: #ffffff; - stroke: rgb(211, 179, 250); - stroke-width: 2px; + fill: rgb(94, 176, 239); + stroke: rgb(255, 255, 255); + stroke-width: 3px; + } + .node-port.user-node { + fill: rgb(161, 140, 130); + stroke: rgb(255, 255, 255); + stroke-width: 3px; } + + .node-port.resource-instance-node { + fill: rgb(94, 176, 239); + stroke: rgb(255, 255, 255); + stroke-width: 4px; + } + /* Link (edge) style for port edges */ .link { fill: none; - stroke: rgb(18, 165, 148); - stroke-width: 3px; - stroke-dasharray: 30, 25; + stroke: rgb(161, 140, 130); + stroke-width: 4px; + stroke-dasharray: 45, 40; animation: dash 0.5s linear infinite; } @keyframes dash { to { - stroke-dashoffset: -55; + stroke-dashoffset: -85; } } /* ------------------------------- RELATIONSHIP CONNECTION EDGES ---------------------------------*/ .link.relationship-connection { - stroke: #f76a0c; + stroke: rgb(163, 131, 117); + stroke-width: 5; } /* ------------------------------- @@ -152,21 +165,58 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { // For example: const graphData = ${JSON.stringify(graphData, null, 2)}; - function flatToForest(flatNodes, flatEdges) { + function flatToForestLayered(flatNodes, flatEdges) { const nodeMap = new Map(); + const relationshipCount = {}; - // Create a node object for each flat node. + // Initialize relationship count for each node. flatNodes.forEach(n => { - nodeMap.set(n.data.id, { - id: n.data.id, - name: n.data.label.trim(), - classes: n.classes, - children: [], - incomingEdges: [], // Optionally store additional edge labels here. - }); + relationshipCount[n.data.id] = 0; + }); + flatEdges.forEach(e => { + const s = e.data.source, + t = e.data.target; + if (relationshipCount[s] !== undefined) relationshipCount[s]++; + if (relationshipCount[t] !== undefined) relationshipCount[t]++; + }); + + // Create a unique node object for each flat node and assign a layer. + flatNodes.forEach(n => { + if (!nodeMap.has(n.data.id)) { + let layer = 1; // default layer + const classesStr = n.classes || ''; + // For user-nodes: if they have any relationships, assign layer 2. + if (classesStr.includes('user-node')) { + layer = relationshipCount[n.data.id] > 0 ? 2 : 1; + } + // For resource-instance-nodes: no relationships → layer 1; one → layer 3; more → layer 4. + else if (classesStr.includes('resource-instance-node')) { + if (relationshipCount[n.data.id] === 0) { + layer = 1; + } else if (relationshipCount[n.data.id] === 1) { + layer = 3; + } else { + layer = 4; + } + } + // For object-nodes, assign layer 5. + else if (classesStr.includes('object-node')) { + layer = 5; + } + + nodeMap.set(n.data.id, { + id: n.data.id, + id2: n.data.id2 || '', + name: n.data.label.trim(), + classes: n.classes, + children: [], + incomingEdges: [], + layer: layer, + }); + } }); - // Keep track of which nodes have already been attached as a child. + // Keep track of which nodes have been attached as children. const attached = new Set(); // Process each edge. @@ -174,42 +224,40 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { const sourceId = edge.data.source; const targetId = edge.data.target; if (nodeMap.has(sourceId) && nodeMap.has(targetId)) { - // Get the source and target node objects. const sourceNode = nodeMap.get(sourceId); const targetNode = nodeMap.get(targetId); - - // If this target hasn't been attached yet, attach it as a child of source. if (!attached.has(targetId)) { sourceNode.children.push(targetNode); attached.add(targetId); - // Optionally, store this edge's label on the target node: targetNode.edgeLabel = edge.data.label; } else { - // If the target is already attached, you can optionally store additional edge labels. - targetNode.incomingEdges.push(edge.data.label); + if (!targetNode.extraRelationships) { + targetNode.extraRelationships = []; + } + targetNode.extraRelationships.push({ + source: sourceId, + label: edge.data.label, + }); } } }); - // The roots are those nodes that never appear as a target. + // Build the forest by iterating over the unique nodes. const forest = []; - flatNodes.forEach(n => { - if (!attached.has(n.data.id)) { - forest.push(nodeMap.get(n.data.id)); + nodeMap.forEach((node, id) => { + if (!attached.has(id)) { + forest.push(node); } }); - - // If no root is found (shouldn't normally happen), return all nodes. if (forest.length === 0) { - flatNodes.forEach(n => forest.push(nodeMap.get(n.data.id))); + nodeMap.forEach(node => forest.push(node)); } - return forest; } - const forest = flatToForest(graphData.nodes, graphData.edges); - - // If multiple roots, wrap them in a dummy root: + const forest = flatToForestLayered(graphData.nodes, graphData.edges); + console.log('Forest:', forest); + // Wrap the forest in a dummy root. const dummyRoot = { id: 'dummy_root', name: 'Root', children: forest }; // Create D3 hierarchy. @@ -218,48 +266,174 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { // ----------------------------- // 2. LAYOUT: Compute Tree Layout // ----------------------------- - const layoutWidth = window.innerWidth * 100; // Expand horizontal space as needed. - const layoutHeight = (window.innerHeight - 100) * 4; // Vertical space remains as needed. + const layoutWidth = window.innerWidth * 100; // horizontal space + const layoutHeight = (window.innerHeight - 100) * 4; // Vertical space - // For a top-to-bottom layout, we want the root at the top. - // One common approach is to set size as [width, height] and then use a vertical link generator. const treeLayout = d3 .tree() .size([layoutWidth, layoutHeight]) - .separation((a, b) => (a.parent === b.parent ? 1.5 : 2.5)); + .separation((a, b) => (a.parent === b.parent ? 2 : 3)); treeLayout(rootHierarchy); + // For each node (except dummy root), if a layer property exists, force d.y = layer * spacing. + const layerSpacing = 800; + rootHierarchy.descendants().forEach(d => { + if (d.depth > 0 && d.data.layer !== undefined) { + d.y = d.data.layer * layerSpacing; + } + }); + // --- horizontal spacing without disturbing user nodes --- + const minSpacing = 1250; + + const nodesByLayer = d3.group( + rootHierarchy + .descendants() + .filter( + d => d.data.id !== 'dummy_root' && d.data.layer !== undefined, + ), + d => d.data.layer, + ); + + // For each layer, check nodes in order of x and nudge non-user nodes if they’re too close. + nodesByLayer.forEach((nodes, layer) => { + nodes.sort((a, b) => a.x - b.x); + for (let i = 1; i < nodes.length; i++) { + const prev = nodes[i - 1]; + const curr = nodes[i]; + if ((curr.data.classes || '').includes('user-node')) continue; + if (curr.x - prev.x < minSpacing) { + curr.x = prev.x + minSpacing; + } + } + }); + // --- Horizontal Alignment Adjustment with Combined Layers 4 & 5 and Clamping --- + // Compute baseline x from layers 2 and 3. + const baselineNodes = rootHierarchy + .descendants() + .filter( + d => + d.data.layer !== undefined && + (d.data.layer === 2 || d.data.layer === 3), + ); + const baselineAvgX = d3.mean(baselineNodes, d => d.x); + + const group1 = rootHierarchy + .descendants() + .filter(d => d.data.layer !== undefined && d.data.layer === 1); + + const group45 = rootHierarchy + .descendants() + .filter( + d => + d.data.layer !== undefined && + (d.data.layer === 4 || d.data.layer === 5), + ); + + const minAllowedX = 50; + const maxAllowedX = layoutWidth - 50; + + // Adjust layer 1: shift its nodes so that the group average x matches the baseline. + if (group1.length > 0) { + const group1AvgX = d3.mean(group1, d => d.x); + const shift1 = baselineAvgX - group1AvgX; + group1.forEach(d => { + d.x += shift1; + d.x = Math.max(minAllowedX, Math.min(d.x, maxAllowedX)); + }); + } + + // Adjust combined group (layers 4 and 5): + if (group45.length > 0) { + const group45AvgX = d3.mean(group45, d => d.x); + const shift45 = baselineAvgX - group45AvgX; + group45.forEach(d => { + d.x += shift45; + d.x = Math.max(minAllowedX, Math.min(d.x, maxAllowedX)); + }); + + // Sort the group by x. + group45.sort((a, b) => a.x - b.x); + const maxGap = 20050; + for (let i = 1; i < group45.length; i++) { + const gap = group45[i].x - group45[i - 1].x; + if (gap > maxGap) { + group45[i].x = group45[i - 1].x + maxGap; + } + } + } + + // ---- NEW: Reassign resource-instance nodes in layer 4 to layer 7 if too far horizontally from connected user nodes ---- + const maxAllowedDistance = 1000; + + rootHierarchy.descendants().forEach(d => { + // Check only resource-instance nodes currently in layer 4. + if ( + d.data.layer === 4 && + (d.data.classes || '').includes('resource-instance-node') + ) { + // Find all user nodes that are connected to this resource instance. + const connectedUserXs = []; + // Iterate over graphData.edges to find edges where this node is the target. + graphData.edges.forEach(e => { + if (e.data.target === d.data.id) { + const sourceNode = graphData.nodes.find( + n => + n.data.id === e.data.source && + (n.classes || '').includes('user-node'), + ); + if (sourceNode) { + const sourceLayoutNode = rootHierarchy + .descendants() + .find(n => n.data.id === sourceNode.data.id); + if (sourceLayoutNode) { + connectedUserXs.push(sourceLayoutNode.x); + } + } + } + }); + if (connectedUserXs.length > 0) { + const avgUserX = d3.mean(connectedUserXs); + // If the horizontal distance exceeds the maximum allowed, update the layer to 7. + if (Math.abs(d.x - avgUserX) > maxAllowedDistance) { + d.data.layer = 7; + d.y = 6.5 * layerSpacing; + } + } + } + }); + // ----------------------------- - // 3. RENDERING: Create SVG and Container Group + // 3. RENDERING: Create SVG and Container Group (unchanged) // ----------------------------- const svg = d3 .select('svg') .attr('viewBox', \`0 0 \${layoutWidth + 100} \${layoutHeight + 100}\`); + + const defs = svg.append('defs'); + defs + .append('pattern') + .attr('id', 'squarePattern') + .attr('x', 0) + .attr('y', 0) + .attr('width', 90) + .attr('height', 90) + .attr('patternUnits', 'userSpaceOnUse') + .html( + "" + + "", + ); + const g = svg.append('g').attr('transform', 'translate(50,50)'); - // ----------------------------- - // 4. RENDER MAIN LINKS (if needed) - // ----------------------------- - // (We will later add port edges and use them for connection.) - // For now, render links from the hierarchy using a vertical link generator. - const linkGenerator = d3 - .linkVertical() - .x(d => d.x) - .y(d => d.y); - - // Uncomment if you want to see the original links: - // g.selectAll("path.link") - // .data(rootHierarchy.links()) - // .enter() - // .append("path") - // .attr("class", "link") - // .attr("d", linkGenerator); + g.insert('rect', ':first-child') + .attr('x', -100000) + .attr('y', -100000) + .attr('width', layoutWidth + 200000) + .attr('height', layoutHeight + 200000) + .attr('fill', 'url(#squarePattern)'); - // ----------------------------- - // 5. RENDER MAIN NODES - // ----------------------------- - // Render nodes from the hierarchy (excluding the dummy root) + const mainNodes = rootHierarchy .descendants() .filter(d => d.data.id !== 'dummy_root'); @@ -277,11 +451,9 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { d3.select(this).classed('dragging', true); }) .on('drag', function (event, d) { - // Update node coordinates. d.x = event.x; d.y = event.y; d3.select(this).attr('transform', \`translate(\${d.x}, \${d.y})\`); - // Update port nodes and port edges. updatePortPositions(d); updatePortEdges(); }) @@ -290,48 +462,189 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { }), ); - // Append rectangle and text for each main node. - nodeGroup - .append('rect') - .attr('width', 650) - .attr('height', 120) - .attr('x', -325) - .attr('y', -60); + // Helper function to truncate strings. + function truncateTo7(str = '') { + return str.length > 7 ? str.substring(0, 7) + '...' : str; + } - nodeGroup - .append('text') - .attr('class', d => 'node-text ' + (d.data.classes || '')) - .attr('dy', '0.15em') - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') - .text(d => { - const fullText = d.data.name; - return fullText.length > 17 - ? fullText.substring(0, 15) + '...' - : fullText; + nodeGroup.each(function (d) { + const nodeSel = d3.select(this); + nodeSel.selectAll('*').remove(); + + let textSel; + const classesStr = d.data.classes || ''; + + if (classesStr.includes('resource-instance-node')) { + // ----------------------------------------- + // resource-instance-node handling + // ----------------------------------------- + const fullText = d.data.name || ''; + const [resourceTypeRaw, resourceIdRaw] = fullText.split('#'); + const resourceType = truncateTo7(resourceTypeRaw || ''); + const resourceId = truncateTo7(resourceIdRaw || ''); + textSel = nodeSel + .append('text') + .attr('class', 'node-text resource-instance-text') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('dy', '0.15em'); + textSel + .append('tspan') + .text(resourceType) + .attr('fill', 'rgb(0, 106, 220)'); + textSel.append('tspan').text('#').attr('fill', 'rgb(94, 176, 239)'); + textSel + .append('tspan') + .text(resourceId) + .attr('fill', 'rgb(0, 106, 220)'); + + // Insert the diamond icon : + requestAnimationFrame(() => { + const bbox = textSel.node().getBBox(); + const diamondMargin = 10; + const diamondWidth = 24; + const diamondScale = 2.9; + const diamondCenterX = + bbox.x + + bbox.width - + diamondMargin - + (diamondWidth * diamondScale) / 2; + const diamondCenterY = bbox.y + bbox.height / 1.6; + + nodeSel + .append('path') + .attr( + 'd', + \`M 16 8 L 23.5 12 L 16 16 L 12 23.5 L 8 16 L 0.5 12 L 8 8 L 12 0.5 L 16 8 Z\`, + ) + .attr('fill', 'none') + .attr('stroke', 'rgb(0, 106, 220)') + .attr('stroke-width', 2.3) + .attr( + 'transform', + \` + translate(\${diamondCenterX}, \${diamondCenterY}) + scale(\${diamondScale}) + translate(-10, -10) + \`, + ); + }); + } else if (classesStr.includes('user-node')) { + // ----------------------------------------- + // user-node handling WITH a "person+star" icon + // ----------------------------------------- + textSel = nodeSel + .append('text') + .attr('class', 'node-text user-node') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('dy', '0.15em') + .text(d.data.name); + + requestAnimationFrame(() => { + const bbox = textSel.node().getBBox(); + + const iconMargin = 10; + const iconWidth = 24; + const iconScale = 3.9; + + const iconCenterX = bbox.x + 30; + const iconCenterY = bbox.y + bbox.height / 1.6; + + const personPlusStarPath = \` + M12,2 + C9.79,2,8,3.79,8,6 + C8,8.21,9.79,10,12,10 + C14.21,10,16,8.21,16,6 + C16,3.79,14.21,2,12,2 + Z + + M12,10 + A8 8 0 0 1 4,18 + + M20,15 + L21,17 + L23,17.5 + L21,18.5 + L21.5,20.5 + L20,19.5 + L18.5,20.5 + L19,18.5 + L17,17.5 + L19,17 + Z +\`.trim(); + + nodeSel + .append('path') + .attr('d', personPlusStarPath.trim()) + .attr('fill', 'none') + .attr('stroke', 'rgb(67, 48, 43)') + .attr('stroke-width', 2.3) + .attr( + 'transform', + \` + translate(\${iconCenterX}, \${iconCenterY}) + scale(\${iconScale}) + translate(-13.5, -11.25) + \`, + ); + }); + } else { + // ----------------------------------------- + // Fallback for all other node types + // ----------------------------------------- + textSel = nodeSel + .append('text') + .attr('class', 'node-text ' + classesStr) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('dy', '0.15em') + .text(d.data.name); + } + + // Insert the rounded rectangle behind the text (common to all): + requestAnimationFrame(() => { + const bbox = textSel.node().getBBox(); + const paddingX = 40; + const paddingY = 42; + const rectWidth = bbox.width + paddingX; + const rectHeight = bbox.height + paddingY; + + nodeSel + .insert('rect', 'text') + .attr('width', rectWidth) + .attr('height', rectHeight) + .attr('x', -rectWidth / 2) + .attr('y', (-rectHeight + 1.3) / 2) + .attr('rx', 12.5) + .attr('ry', 12.5); }); + }); // ----------------------------- // 6. RENDER DUMMY PORT NODES // ----------------------------- - // For each main node, we create two dummy port nodes: - // One for incoming (id: mainID_in) and one for outgoing (id: mainID_out). - // Compute an array for port nodes based on the mainNodes data. const portData = []; mainNodes.forEach(d => { + const inheritedClass = d.data.classes || ''; portData.push({ id: d.data.id + '_in', main: d.data.id, type: 'in', - x: d.x, // Initially, same as main node. - y: d.y - 70, // Offset above. + x: d.x, + y: d.y - 75, + classes: inheritedClass, + layer: d.data.layer, }); portData.push({ id: d.data.id + '_out', main: d.data.id, type: 'out', - x: d.x, // Initially, same as main node. - y: d.y + 70, // Offset below. + x: d.x, + y: d.y + 78, + classes: inheritedClass, + layer: d.data.layer, }); }); @@ -340,21 +653,19 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { .data(portData, d => d.id) .enter() .append('circle') - .attr('class', 'node-port') + .attr('class', d => \`node-port \${d.classes}\`) .attr('id', d => d.id) - .attr('r', 5) + .attr('r', 10) .attr('cx', d => d.x) .attr('cy', d => d.y); // ----------------------------- - // 7. RENDER PORT EDGES + // 7. RENDER PORT EDGES // ----------------------------- - // For every link in the hierarchy (parent-to-child), - // create a new edge that connects the parent's outgoing port to the child's incoming port. const portEdges = rootHierarchy.links().map(link => ({ source: link.source.data.id + '_out', target: link.target.data.id + '_in', - label: link.target.data.edgeLabel, // optional + label: link.target.data.edgeLabel, classes: ( graphData.edges.find( @@ -365,25 +676,49 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { ).classes || 'relationship-connection', })); console.log(portEdges); - // Render these port edges. + + // Generate extra port edges from extraRelationships by traversing the hierarchy. + const extraPortEdges = []; + rootHierarchy.descendants().forEach(d => { + if (d.data.extraRelationships) { + d.data.extraRelationships.forEach(rel => { + extraPortEdges.push({ + source: rel.source + '_out', + target: d.data.id + '_in', + label: rel.label, + classes: 'relationship-connection extra', + }); + }); + } + }); + + const allEdges = portEdges.concat(extraPortEdges); + + // Render extra port edges. + const extraPortEdgeSel = g + .selectAll('path.extra-port-link') + .data(extraPortEdges) + .enter() + .append('path') + .attr('class', d => 'link extra-port-link ' + (d.classes || '')) + .attr('d', portLinkGenerator) + .style('opacity', 0.5); + const portEdgeSel = g .selectAll('path.port-link') .data(portEdges) .enter() .append('path') .attr('class', d => 'link port-link ' + (d.classes || '')) - .attr('d', portLinkGenerator); - - // Define a function to generate the path for a port edge. + .attr('d', portLinkGenerator) + .style('opacity', 0.5); function portLinkGenerator(d) { - // For parent's out port and child's in port, get positions from the rendered circles. const sourceCircle = g.select(\`circle.node-port[id="\${d.source}"]\`).node() || document.getElementById(d.source); const targetCircle = g.select(\`circle.node-port[id="\${d.target}"]\`).node() || document.getElementById(d.target); - // Alternatively, find in our portData array: const source = portData.find(n => n.id === d.source); const target = portData.find(n => n.id === d.target); if (!source || !target) return ''; @@ -391,38 +726,45 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { sY = source.y; const tX = target.x, tY = target.y; - // Compute control points for an S-shaped curve: - const cp1 = [sX, sY + 270]; - const cp2 = [tX, tY - 270]; + // Set default control points. + let cp1 = [sX, sY + 270]; + let cp2 = [tX, tY - 270]; + + // If the target node is in layer 4, adjust the control points. + if (target.layer === 4) { + cp1 = [sX, sY + 600]; + cp2 = [tX, tY - 600]; + } + if (target.layer === 7) { + cp1 = [sX, sY + 5000]; + cp2 = [tX, tY - 1000]; + } + return \`M \${sX} \${sY} C \${cp1[0]} \${cp1[1]}, \${cp2[0]} \${cp2[1]}, \${tX} \${tY}\`; } // ----------------------------- - // 8. Drag Behavior: Update Port Nodes and Edges on Drag + // 8. DRAG BEHAVIOR: Update Port Nodes and Edges on Drag // ----------------------------- function updatePortPositions(d) { - // Update portData for the main node d. portData.forEach(p => { if (p.main === d.data.id) { if (p.type === 'in') { p.x = d.x; - p.y = d.y - 40; + p.y = d.y - 75; } else if (p.type === 'out') { p.x = d.x; - p.y = d.y + 40; + p.y = d.y + 80; } } }); - // Update the SVG circles for port nodes. portNodeSel.attr('cx', d => d.x).attr('cy', d => d.y); } - function updatePortEdges() { g.selectAll('path.port-link').attr('d', portLinkGenerator); + g.selectAll('path.extra-port-link').attr('d', portLinkGenerator); updateEdgeLabels(); } - - // Reassign drag behavior on main nodes to update ports and port edges. nodeGroup.call( d3 .drag() @@ -443,36 +785,76 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { ); // ----------------------------- - // 9. Enable Zoom and Pan + // 9. ENABLE ZOOM AND PAN // ----------------------------- - // Create and store your zoom behavior instance. const zoomBehavior = d3 .zoom() - .scaleExtent([0.5, 22]) + .scaleExtent([6, 22]) .on('zoom', event => { g.attr('transform', event.transform); }); - - // Apply the zoom behavior to your SVG. svg.call(zoomBehavior); - - // Reset any transform so we get a proper bounding box. g.attr('transform', 'translate(0,0)'); + const initialScale = 10; requestAnimationFrame(() => { - const bbox = g.node().getBBox(); const svgWidth = svg.node().clientWidth; const svgHeight = svg.node().clientHeight; const centerX = svgWidth / 2; const centerY = svgHeight / 2; - const graphCenterX = bbox.x + bbox.width / 2; - const graphCenterY = bbox.y + bbox.height / 2; - const initialScale = 15; // desired zoom level - // Multiply the graph center coordinates by the scale factor: - const translateX = centerX - initialScale * graphCenterX; - const translateY = centerY - initialScale * graphCenterY; + let centerPoint = null; + // Filter all nodes (excluding dummy_root) that have "user-node" in their classes. + const userNodes = rootHierarchy + .descendants() + .filter( + d => + d.data.id !== 'dummy_root' && + (d.data.classes || '').includes('user-node'), + ); + + if (userNodes.length > 0) { + let maxCount = -1; + let mostConnectedUser = null; + + // For each user node, count the number of connections. + userNodes.forEach(node => { + const count = graphData.edges.reduce((acc, edge) => { + return ( + acc + + (edge.data.source === node.data.id || + edge.data.target === node.data.id + ? 1 + : 0) + ); + }, 0); + if (count > maxCount) { + maxCount = count; + mostConnectedUser = node; + } + }); + + if (mostConnectedUser) { + centerPoint = { + x: mostConnectedUser.x - mostConnectedUser.x / 11, + y: mostConnectedUser.y - mostConnectedUser.y * -0.7, + }; + } + } + + // Fallback: center on the entire graph's bounding box if no user node was found. + if (!centerPoint) { + const bbox = g.node().getBBox(); + centerPoint = { + x: bbox.x + bbox.width / 2, + y: bbox.y + bbox.height / 2, + }; + } + + // Calculate the translation so that centerPoint is placed at the center of the SVG. + const translateX = centerX - initialScale * centerPoint.x; + const translateY = centerY - initialScale * centerPoint.y; const initialTransform = d3.zoomIdentity .translate(translateX, translateY) .scale(initialScale); @@ -481,93 +863,171 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { }); // ----------------------------- - // 10. Update Link Paths Function (if needed) + // 10. CREATE & UPDATE EDGE LABEL GROUPS WITH BACKGROUND (initially hidden) // ----------------------------- - // In this example, we update only port edges. - // If you had additional links to update, call updatePortEdges() accordingly. - // 1. Create (or select) a group for edge labels (if not already created) const edgeLabelGroup = g - .selectAll('text.edge-label') - .data(portEdges, d => d.source + '|' + d.target) + .selectAll('g.edge-label-group') + .data(allEdges, d => d.source + '|' + d.target) .enter() + .append('g') + .attr('class', d => 'edge-label-group ' + (d.classes || '')) + .style('opacity', 0); + + // Append a background rectangle into each group (no explicit opacity set here). + edgeLabelGroup + .append('rect') + .attr('class', 'edge-label-bg') + .style('fill', 'rgb(206, 231, 254)') + .attr('rx', 5) + .attr('ry', 5); + + // Append the text element inside each group. + edgeLabelGroup .append('text') - .attr('class', d => 'edge-label ' + (d.classes || '')) - .attr('dy', -5) // Adjust vertical offset as needed + .attr('class', 'edge-label') + .attr('dy', -5) .attr('text-anchor', 'middle') - .style('fill', '#000000') // Label color + .style('fill', 'rgb(15, 48, 88)') .style('font-size', '60px') .text(d => d.label); - updateEdgeLabels(); + // Once rendered, measure each text element and update the background rectangle. + edgeLabelGroup.each(function () { + const group = d3.select(this); + const textElem = group.select('text.edge-label'); + requestAnimationFrame(() => { + const bbox = textElem.node().getBBox(); + const paddingX = 70; // horizontal padding + const paddingY = 50; // vertical padding + const rectWidth = bbox.width + paddingX; + const rectHeight = bbox.height + paddingY; + group + .select('rect.edge-label-bg') + .attr('width', rectWidth) + .attr('height', rectHeight) + .attr('x', -rectWidth / 2) + .attr('y', -rectHeight / 1.4); + }); + }); - // 2. Create a function to update label positions based on the port node positions. - // This function calculates the midpoint between the source and target port nodes. + // Updated updateEdgeLabels function: update the transform on the edge-label groups. function updateEdgeLabels() { - g.selectAll('text.edge-label') - .data(portEdges, d => d.source + '|' + d.target) + edgeLabelGroup .attr('transform', d => { - // Use the rendered port nodes (by their IDs) const sourceCircle = g .select(\`circle.node-port[id="\${d.source}"]\`) .node(); const targetCircle = g .select(\`circle.node-port[id="\${d.target}"]\`) .node(); - if (sourceCircle && targetCircle) { const sX = +sourceCircle.getAttribute('cx'); const sY = +sourceCircle.getAttribute('cy'); const tX = +targetCircle.getAttribute('cx'); const tY = +targetCircle.getAttribute('cy'); - const midX = (sX + tX) / 2; - const midY = (sY + tY) / 2; - return \`translate(\${midX}, \${midY})\`; + + // Look up target port data to check its layer. + const targetPort = portData.find(n => n.id === d.target); + if (targetPort && targetPort.layer === 7) { + // For layer 7 edges, use the modified control points as in portLinkGenerator. + let cp1 = [sX, sY + 5500]; + let cp2 = [tX, tY - 1000]; + // Compute the cubic Bezier midpoint at t=0.5: + const midX = + 0.125 * sX + 0.375 * cp1[0] + 0.375 * cp2[0] + 0.125 * tX; + const midY = + 0.125 * sY + 0.375 * cp1[1] + 0.375 * cp2[1] + 0.125 * tY; + return \`translate(\${midX}, \${midY})\`; + } else { + const midX = (sX + tX) / 2; + const midY = (sY + tY) / 2; + return \`translate(\${midX}, \${midY})\`; + } } return ''; }) + .select('text.edge-label') .text(d => d.label); } + updateEdgeLabels(); - // Create a tooltip div and append it to the body. + // ----------------------------- + // TOOLTIP + // ----------------------------- const tooltip = document.createElement('div'); tooltip.id = 'tooltip'; tooltip.style.position = 'absolute'; tooltip.style.background = 'rgba(0, 0, 0, 0.7)'; tooltip.style.color = '#fff'; tooltip.style.padding = '5px 10px'; - tooltip.style.borderRadius = '3px'; + tooltip.style.borderRadius = '12px'; tooltip.style.fontFamily = "'Manrope', Arial, sans-serif"; - tooltip.style.fontSize = '18px'; - tooltip.style.display = 'none'; // Initially hidden + tooltip.style.fontSize = '11.5px'; + tooltip.style.whiteSpace = 'pre-line'; + tooltip.style.display = 'none'; document.body.appendChild(tooltip); nodeGroup .on('mouseover', function (event, d) { - // Set the tooltip content to the full text (or any desired content). - tooltip.innerText = d.data.name; + let tooltipText = d.data.name; + if ((d.data.classes || '').includes('resource-instance-node')) { + tooltipText += \`'\n'\` + d.data.id2; + } + tooltip.innerText = tooltipText; tooltip.style.display = 'block'; - - // Create a Popper instance to position the tooltip relative to the hovered node. - // We pass 'this' (the DOM element for the node) as the reference. Popper.createPopper(this, tooltip, { - placement: 'bottom', // or "bottom", "right", etc. - modifiers: [ - { - name: 'offset', - options: { - offset: [0, 8], // Adjust the offset as needed. - }, - }, - ], + placement: 'bottom', + modifiers: [{ name: 'offset', options: { offset: [0, 15] } }], }); - }) - .on('mousemove', function (event, d) { - // Optionally update the tooltip position if needed; Popper generally handles this. - // You might want to update the content or force an update if required. + // For connected edges: + g.selectAll('path.port-link') + .filter( + edge => + edge.source === d.data.id + '_out' || + edge.target === d.data.id + '_in', + ) + .transition() + .duration(200) + .style('opacity', 2); + + // For connected edge label groups: + g.selectAll('g.edge-label-group') + .filter( + edge => + edge.source === d.data.id + '_out' || + edge.target === d.data.id + '_in', + ) + .transition() + .duration(200) + .style('opacity', 1.5); }) .on('mouseout', function (event, d) { tooltip.style.display = 'none'; + // Revert connected edges to 50% opacity: + g.selectAll('path.port-link') + .filter( + edge => + edge.source === d.data.id + '_out' || + edge.target === d.data.id + '_in', + ) + .transition() + .duration(200) + .style('opacity', 0.5); + + g.selectAll('g.edge-label-group') + .filter( + edge => + edge.source === d.data.id + '_out' || + edge.target === d.data.id + '_in', + ) + .transition() + .duration(200) + .style('opacity', 0); }); + + // Ensure that all node groups are raised above edges. + g.selectAll('g.node-main').raise(); + g.selectAll('circle.node-port').raise(); diff --git a/source/components/generateGraphData.ts b/source/components/generateGraphData.ts index b5cdbc9..a9c9686 100644 --- a/source/components/generateGraphData.ts +++ b/source/components/generateGraphData.ts @@ -3,6 +3,7 @@ type ResourceInstance = { label: string; value: string; id: string; + id2: string; }; type Relationship = { @@ -28,7 +29,7 @@ export const generateGraphData = ( ) => { const nodes: { data: { id: string; label: string }; classes?: string }[] = resources.map(resource => ({ - data: { id: resource.id, label: ` ${resource.label}` }, + data: { id: resource.id, label: ` ${resource.label}`, id2: resource.id2 }, classes: 'resource-instance-node', })); @@ -43,19 +44,29 @@ export const generateGraphData = ( if (!existingNodeIds.has(relation.Object)) { nodes.push({ data: { id: relation.Object, label: `${relation.Object}` }, + classes: 'object-node', }); existingNodeIds.add(relation.Object); } if (resourceId !== relation.Object) { - edges.push({ - data: { - source: resourceId, - target: relation.Object, - label: `IS ${relation.label} OF`, - }, - classes: 'relationship-connection', // Class for orange lines - }); + // Check if an edge with the same source, target, and label already exists. + const exists = edges.some( + edge => + edge.data.source === resourceId && + edge.data.target === relation.Object && + edge.data.label === `IS ${relation.label} OF`, + ); + if (!exists) { + edges.push({ + data: { + source: resourceId, + target: relation.Object, + label: `IS ${relation.label} OF`, + }, + classes: 'relationship-connection', + }); + } } }); }); @@ -76,14 +87,23 @@ export const generateGraphData = ( } if (relation.subjectId !== relation.objectId) { - edges.push({ - data: { - source: relation.subjectId, - target: relation.objectId, - label: relation.label, - }, - classes: 'relationship-connection', // Class for orange lines - }); + // Check if an edge with the same source, target, and label already exists. + const exists = edges.some( + edge => + edge.data.source === relation.subjectId && + edge.data.target === relation.objectId && + edge.data.label === relation.label, + ); + if (!exists) { + edges.push({ + data: { + source: relation.subjectId, + target: relation.objectId, + label: relation.label, + }, + classes: 'relationship-connection', // Class for orange lines + }); + } } }); }); From 3a2af2bfb251f92e7087268e04bb9180050a4c65 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Thu, 20 Feb 2025 17:37:08 +0530 Subject: [PATCH 21/25] added a minimap for user navigation introduced a new minimap fixed styling issues --- source/components/HtmlGraphSaver.ts | 242 +++++++++++++++++++++++++--- source/components/graphCommand.tsx | 8 +- 2 files changed, 221 insertions(+), 29 deletions(-) diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 9303fec..8810138 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -148,15 +148,27 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { fill: #ffffff; font-size: 25px; font-family: 'Manrope', Arial, sans-serif; - /* If you need a background for the text (as in Cytoscape), - you'll need to render a separate rectangle behind the text. - This snippet just sets the text styling. */ + } + #minimap-container { + position: fixed; + bottom: 10px; + right: 10px; + width: 400px; + height: 100px; + z-index: 9999; + background: transparent; /* Transparent background */ + border: 1px solid #ccc; + border-radius: 25px; + overflow: hidden; + pointer-events: none; /* Do not block mouse events */ }
Permit ReBAC Graph
+
+ diff --git a/source/components/graphCommand.tsx b/source/components/graphCommand.tsx index aa445b3..e025d70 100644 --- a/source/components/graphCommand.tsx +++ b/source/components/graphCommand.tsx @@ -150,11 +150,13 @@ export default function Graph({ options }: Props) { while (hasMoreData) { const resourceResponse = await apiCall( - `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances?detailed=true&page=${Page}&per_page=${per_Page}`, + `v2/facts/${selectedProject.value}/${selectedEnvironment.value}/resource_instances/detailed?page=${Page}&per_page=${per_Page}`, authToken, ); + const resourceArray = + resourceResponse.response.data || resourceResponse.response; - const resourcesData = resourceResponse.response.map((res: any) => ({ + const resourcesData = resourceArray.map((res: any) => ({ label: `${res.resource}#${res.resource_id}`, value: res.id, id: res.id, @@ -166,7 +168,7 @@ export default function Graph({ options }: Props) { allResourcesData = [...allResourcesData, ...resourcesData]; // Check if there are more pages to fetch - hasMoreData = resourceResponse.response.length === per_Page; + hasMoreData = resourceArray.length === per_Page; Page++; } From abda79c45cb9a4eec029d7f60a082699f007ca55 Mon Sep 17 00:00:00 2001 From: harshtech123 Date: Sun, 9 Mar 2025 18:22:50 +0530 Subject: [PATCH 22/25] updates in logic to handle each type of cases --- source/commands/graph.tsx | 2 +- source/components/HtmlGraphSaver.ts | 329 ++++++++++++++++++------- source/components/generateGraphData.ts | 8 +- source/components/graphCommand.tsx | 34 ++- source/lib/api.ts | 2 +- 5 files changed, 282 insertions(+), 93 deletions(-) diff --git a/source/commands/graph.tsx b/source/commands/graph.tsx index 0b5a11b..2367d33 100644 --- a/source/commands/graph.tsx +++ b/source/commands/graph.tsx @@ -20,7 +20,7 @@ type Props = { export default function graph({ options }: Props) { return ( - + ); diff --git a/source/components/HtmlGraphSaver.ts b/source/components/HtmlGraphSaver.ts index 8810138..0ddf4a3 100644 --- a/source/components/HtmlGraphSaver.ts +++ b/source/components/HtmlGraphSaver.ts @@ -2,7 +2,38 @@ import { writeFileSync } from 'fs'; import { resolve } from 'path'; import open from 'open'; -export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { +// Define the data structure for a graph node's data +interface GraphNodeData { + id: string; + label: string; +} + +// Define a graph node; `classes` is optional +interface GraphNode { + data: GraphNodeData; + classes?: string; +} + +// Define the data structure for a graph edge's data +interface GraphEdgeData { + source: string; + target: string; + label: string; +} + +// Define a graph edge; here `classes` is required +interface GraphEdge { + data: GraphEdgeData; + classes: string; +} + +// Define the overall GraphData structure +interface GraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +export const saveHTMLGraph = (graphData: GraphData) => { const outputHTMLPath = resolve(process.cwd(), 'permit-graph.html'); const htmlTemplate = ` @@ -149,6 +180,32 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => { font-size: 25px; font-family: 'Manrope', Arial, sans-serif; } + + /* Zoom Controls Styling */ + #zoom-controls { + position: fixed; + bottom: 10px; + left: 10px; + display: flex; + flex-direction: column; + gap: 5px; + z-index: 10000; + } + #zoom-controls button { + width: 40px; + height: 40px; + border: none; + border-radius: 5px; + background-color: rgb(254, 248, 244); + color: rgb(69, 30, 17); + font-size: 24px; + font-weight: bold; + cursor: pointer; + } + #zoom-controls button svg { + width: 24px; + height: 24px; + } #minimap-container { position: fixed; bottom: 10px; @@ -168,6 +225,12 @@ export const saveHTMLGraph = (graphData: { nodes: any[]; edges: any[] }) => {
Permit ReBAC Graph
+ +
+ + + +