Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Resource associations visualization #243

Merged
merged 2 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ const createEdgesFromEnvironmentToDeployments = (
}));

const createEdgesFromDeploymentsToResources = (relationships: Relationships) =>
relationships.map((resource) => {
relationships.nodes.map((resource) => {
const { parent } = resource;
if (parent == null) return null;

const allReleaseJobTriggers = relationships
const allReleaseJobTriggers = relationships.nodes
.flatMap((r) => r.workspace.systems)
.flatMap((s) => s.environments)
.flatMap((e) => e.latestActiveReleases)
Expand All @@ -93,26 +93,48 @@ const createEdgesFromDeploymentsToResources = (relationships: Relationships) =>
});

export const getEdges = (relationships: Relationships) => {
const resourceToEnvEdges = relationships.flatMap((r) =>
const resourceToEnvEdges = relationships.nodes.flatMap((r) =>
createEdgesFromResourceToEnvironments(
r,
r.workspace.systems.flatMap((s) => s.environments),
),
);
const environmentToDeploymentEdges = relationships.flatMap((r) =>
const environmentToDeploymentEdges = relationships.nodes.flatMap((r) =>
r.workspace.systems.flatMap((s) =>
createEdgesFromEnvironmentToDeployments(s.environments, s.deployments),
),
);
const providerEdges = relationships.flatMap((r) =>
const providerEdges = relationships.nodes.flatMap((r) =>
r.provider != null ? [createEdgeFromProviderToResource(r.provider, r)] : [],
);
const deploymentEdges = createEdgesFromDeploymentsToResources(relationships);

const { resource } = relationships;

const fromEdges = relationships.associations.from.map((r) => ({
id: `${r.resource.id}-${resource.id}`,
source: r.resource.id,
target: resource.id,
style: { stroke: colors.neutral[800] },
markerEnd,
label: r.type,
}));

const toEdges = relationships.associations.to.map((r) => ({
id: `${resource.id}-${r.resource.id}`,
source: resource.id,
target: r.resource.id,
style: { stroke: colors.neutral[800] },
markerEnd,
label: r.type,
}));

return [
...resourceToEnvEdges,
...environmentToDeploymentEdges,
...providerEdges,
...deploymentEdges,
...fromEdges,
...toEdges,
].filter(isPresent);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { Handle, Position } from "reactflow";
import { useEnvironmentDrawer } from "~/app/[workspaceSlug]/(app)/_components/environment-drawer/EnvironmentDrawer";

type Environment = NonNullable<
RouterOutputs["resource"]["relationships"][number]
>["workspace"]["systems"][number]["environments"][number];
RouterOutputs["resource"]["relationships"]
>["nodes"][number]["workspace"]["systems"][number]["environments"][number];

type EnvironmentNodeProps = NodeProps<{
label: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ export const nodeTypes: NodeTypes = {
};

const getResourceNodes = (relationships: Relationships) =>
relationships.map((r) => ({
relationships.nodes.map((r) => ({
id: r.id,
type: NodeType.Resource,
data: { ...r, label: r.identifier },
position: { x: 0, y: 0 },
}));

const getProviderNodes = (relationships: Relationships) =>
relationships
relationships.nodes
.map((r) =>
r.provider != null
? {
Expand All @@ -46,7 +46,7 @@ const getProviderNodes = (relationships: Relationships) =>
.filter(isPresent);

const getEnvironmentNodes = (relationships: Relationships) =>
relationships
relationships.nodes
.flatMap((r) => r.workspace.systems)
.flatMap((s) => s.environments.map((e) => ({ s, e })))
.map(({ s, e }) => ({
Expand All @@ -57,7 +57,7 @@ const getEnvironmentNodes = (relationships: Relationships) =>
}));

const getDeploymentNodes = (relationships: Relationships) =>
relationships.flatMap((r) =>
relationships.nodes.flatMap((r) =>
r.workspace.systems.flatMap((system) =>
system.environments.flatMap((environment) =>
system.deployments.map((deployment) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { notFound } from "next/navigation";

import { api } from "~/trpc/server";
import { ResourceVisualizationDiagramProvider } from "./ResourceVisualizationDiagram";

Expand All @@ -7,5 +9,6 @@ export default async function VisualizePage({
params: { targetId: string };
}) {
const relationships = await api.resource.relationships(targetId);
if (relationships == null) return notFound();
return <ResourceVisualizationDiagramProvider relationships={relationships} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ const getUndirectedGraph = (
const graph: Record<string, Set<string>> = {};

for (const relationship of relationships) {
if (!graph[relationship.sourceId]) graph[relationship.sourceId] = new Set();
if (!graph[relationship.targetId]) graph[relationship.targetId] = new Set();
graph[relationship.sourceId]!.add(relationship.targetId);
graph[relationship.targetId]!.add(relationship.sourceId);
if (!graph[relationship.fromIdentifier])
graph[relationship.fromIdentifier] = new Set();
if (!graph[relationship.toIdentifier])
graph[relationship.toIdentifier] = new Set();
graph[relationship.fromIdentifier]!.add(relationship.toIdentifier);
graph[relationship.toIdentifier]!.add(relationship.fromIdentifier);
}
return Object.fromEntries(
Object.entries(graph).map(([key, value]) => [key, Array.from(value)]),
Expand All @@ -101,23 +103,25 @@ const TargetDiagramDependencies: React.FC<DependenciesDiagramProps> = ({
}) => {
const [nodes, _, onNodesChange] = useNodesState(
targets.map((t) => ({
id: t.id,
id: t.identifier,
type: "target",
position: { x: 100, y: 100 },
data: {
...t,
targetId,
isOrphanNode: !relationships.some(
(r) => r.targetId === t.id || r.sourceId === t.id,
(r) =>
r.toIdentifier === t.identifier ||
r.fromIdentifier === t.identifier,
),
},
})),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
relationships.map((t) => ({
id: `${t.sourceId}-${t.targetId}`,
source: t.sourceId,
target: t.targetId,
id: `${t.fromIdentifier}-${t.toIdentifier}`,
source: t.fromIdentifier,
target: t.toIdentifier,
markerEnd: {
type: MarkerType.Arrow,
color: colors.neutral[700],
Expand All @@ -135,9 +139,9 @@ const TargetDiagramDependencies: React.FC<DependenciesDiagramProps> = ({
const resetEdges = () =>
setEdges(
relationships.map((t) => ({
id: `${t.sourceId}-${t.targetId}`,
source: t.sourceId,
target: t.targetId,
id: `${t.fromIdentifier}-${t.toIdentifier}`,
source: t.fromIdentifier,
target: t.toIdentifier,
markerEnd: {
type: MarkerType.Arrow,
color: colors.neutral[700],
Expand Down Expand Up @@ -174,14 +178,14 @@ const TargetDiagramDependencies: React.FC<DependenciesDiagramProps> = ({
const highlightedEdges = getHighlightedEdgesFromPath(nodesInPath);
const newEdges = relationships.map((t) => {
const isHighlighted = highlightedEdges.includes(
`${t.sourceId}-${t.targetId}`,
`${t.fromIdentifier}-${t.toIdentifier}`,
);
const color = isHighlighted ? colors.blue[500] : colors.neutral[700];

return {
id: `${t.sourceId}-${t.targetId}`,
source: t.sourceId,
target: t.targetId,
id: `${t.fromIdentifier}-${t.toIdentifier}`,
source: t.fromIdentifier,
target: t.toIdentifier,
markerEnd: { type: MarkerType.Arrow, color },
style: { stroke: color },
label: t.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,25 @@ const TargetDiagram: React.FC<{
}> = ({ relationships, targets, targetId }) => {
const [nodes, _, onNodesChange] = useNodesState(
targets.map((t) => ({
id: t.id,
id: t.identifier,
type: "target",
position: { x: 100, y: 100 },
data: {
...t,
targetId,
isOrphanNode: !relationships.some(
(r) => r.targetId === t.id || r.sourceId === t.id,
(r) =>
r.toIdentifier === t.identifier ||
r.fromIdentifier === t.identifier,
),
},
})),
);
const [edges, __, onEdgesChange] = useEdgesState(
relationships.map((t) => ({
id: `${t.sourceId}-${t.targetId}`,
source: t.sourceId,
target: t.targetId,
id: `${t.fromIdentifier}-${t.toIdentifier}`,
source: t.fromIdentifier,
target: t.toIdentifier,
markerEnd: { type: MarkerType.Arrow, color: colors.neutral[700] },
style: { stroke: colors.neutral[700] },
label: t.type,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { and, eq, isNull, takeFirstOrNull } from "@ctrlplane/db";
import { and, eq } from "@ctrlplane/db";
import * as SCHEMA from "@ctrlplane/db/schema";

import { authn } from "../../auth";
Expand All @@ -21,48 +21,29 @@ export const POST = request()
try {
const { body, db } = ctx;

const fromResource = await db
.select()
.from(SCHEMA.resource)
.where(
and(
eq(SCHEMA.resource.identifier, body.fromIdentifier),
eq(SCHEMA.resource.workspaceId, body.workspaceId),
isNull(SCHEMA.resource.deletedAt),
),
)
.then(takeFirstOrNull);
if (!fromResource)
return Response.json(
{ error: `${body.fromIdentifier} not found` },
{ status: 404 },
);
const inWorkspace = eq(SCHEMA.resource.workspaceId, body.workspaceId);
const fromResource = await db.query.resource.findFirst({
where: and(
inWorkspace,
eq(SCHEMA.resource.identifier, body.fromIdentifier),
),
});
Comment on lines +24 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Verify resource existence before creating relationship

The current implementation doesn't validate if the resources actually exist before creating the relationship. If either resource is not found, it will still create the relationship using the provided identifiers.

Apply this diff to add proper validation:

      const fromResource = await db.query.resource.findFirst({
        where: and(
          inWorkspace,
          eq(SCHEMA.resource.identifier, body.fromIdentifier),
        ),
      });
+     if (!fromResource) {
+       return Response.json(
+         { error: "From resource not found" },
+         { status: 404 },
+       );
+     }

      const toResource = await db.query.resource.findFirst({
        where: and(
          inWorkspace,
          eq(SCHEMA.resource.identifier, body.toIdentifier),
        ),
      });
+     if (!toResource) {
+       return Response.json(
+         { error: "To resource not found" },
+         { status: 404 },
+       );
+     }

Also applies to: 32-37


const toResource = await db
.select()
.from(SCHEMA.resource)
.where(
and(
eq(SCHEMA.resource.identifier, body.toIdentifier),
eq(SCHEMA.resource.workspaceId, body.workspaceId),
isNull(SCHEMA.resource.deletedAt),
),
)
.then(takeFirstOrNull);
if (!toResource)
return Response.json(
{ error: `${body.toIdentifier} not found` },
{ status: 404 },
);
const toResource = await db.query.resource.findFirst({
where: and(
inWorkspace,
eq(SCHEMA.resource.identifier, body.toIdentifier),
),
});

await db.insert(SCHEMA.resourceRelationship).values({
sourceId: fromResource.id,
targetId: toResource.id,
type: body.type,
const relationship = await db.insert(SCHEMA.resourceRelationship).values({
...body,
fromIdentifier: fromResource?.identifier ?? body.fromIdentifier,
toIdentifier: toResource?.identifier ?? body.toIdentifier,
Comment on lines +39 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use validated resource identifiers

Since we're fetching the resources first, we should use their validated identifiers instead of falling back to the request body.

Apply this diff:

      const relationship = await db.insert(SCHEMA.resourceRelationship).values({
        ...body,
-       fromIdentifier: fromResource?.identifier ?? body.fromIdentifier,
-       toIdentifier: toResource?.identifier ?? body.toIdentifier,
+       fromIdentifier: fromResource.identifier,
+       toIdentifier: toResource.identifier,
      });

Committable suggestion skipped: line range outside the PR's diff.

});

return Response.json(
{ message: "Relationship created" },
{ message: "Relationship created", relationship },
{ status: 200 },
);
} catch (error) {
Expand Down
Loading
Loading