Render mermaid diagram per risk assessment scope

Each scope card now shows a flowchart of its nodes, processes, and
threats, with a distinct shape per type: stadium for entities,
hexagon for boundaries, rectangle for assets, cylinder for data, and
a red hexagon for threats attached via dashed edges to their process
target. The Mermaid source is built on the backend and exposed as a
new `mermaid` field on RiskAssessmentScope; the frontend just renders
it via @probo/ui's MermaidDiagram and shows a copy button + legend.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-12 17:31:35 +02:00
parent b9262b5150
commit eedfdcecc8
11 changed files with 714 additions and 5 deletions

View File

@@ -27,12 +27,15 @@ import {
Select,
useDialogRef,
} from "@probo/ui";
import { useState } from "react";
import { Suspense, useState } from "react";
import { useForm } from "react-hook-form";
import { graphql, useMutation } from "react-relay";
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
import type { CreateScenarioInScopeDialogLinkRiskMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkRiskMutation.graphql";
import type { CreateScenarioInScopeDialogLinkThreatMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkThreatMutation.graphql";
import type { CreateScenarioInScopeDialogMutation } from "#/__generated__/core/CreateScenarioInScopeDialogMutation.graphql";
import type { CreateScenarioInScopeDialogRisksQuery } from "#/__generated__/core/CreateScenarioInScopeDialogRisksQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const createScenarioMutation = graphql`
mutation CreateScenarioInScopeDialogMutation(
@@ -56,11 +59,75 @@ const linkThreatMutation = graphql`
$input: LinkRiskAssessmentScenarioThreatInput!
) {
linkRiskAssessmentScenarioThreat(input: $input) {
riskAssessmentScenario { id }
riskAssessmentScenario {
id
threats(first: 10) { edges { node { id name } } }
}
}
}
`;
const linkRiskMutation = graphql`
mutation CreateScenarioInScopeDialogLinkRiskMutation(
$input: LinkRiskAssessmentScenarioRiskInput!
) {
linkRiskAssessmentScenarioRisk(input: $input) {
riskAssessmentScenario {
id
risks(first: 10) { edges { node { id name } } }
}
riskAssessmentScenarioEdge { node { id } }
}
}
`;
const risksQuery = graphql`
query CreateScenarioInScopeDialogRisksQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
risks(first: 100) {
edges { node { id name } }
}
}
}
}
`;
function RiskSelector(props: {
selectedRisks: Map<string, string>;
onSelect: (id: string, name: string) => void;
}) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<CreateScenarioInScopeDialogRisksQuery>(
risksQuery,
{ organizationId },
{ fetchPolicy: "store-or-network" },
);
const allRisks = data.node?.risks?.edges?.map(e => e.node) ?? [];
const available = allRisks.filter(r => !props.selectedRisks.has(r.id));
if (available.length === 0) {
return <p className="text-xs text-txt-tertiary">{__("No more risks available.")}</p>;
}
return (
<Select
key={props.selectedRisks.size}
placeholder={__("Select a risk to link...")}
onValueChange={(riskId) => {
if (typeof riskId !== "string") return;
const risk = allRisks.find(r => r.id === riskId);
if (risk) props.onSelect(risk.id, risk.name);
}}
>
{available.map(r => (
<Option key={r.id} value={r.id}>{r.name}</Option>
))}
</Select>
);
}
export function CreateScenarioInScopeDialog(props: {
scopeId: string;
threats: { id: string; name: string }[];
@@ -69,8 +136,10 @@ export function CreateScenarioInScopeDialog(props: {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [selectedThreats, setSelectedThreats] = useState<Map<string, string>>(new Map());
const [selectedRisks, setSelectedRisks] = useState<Map<string, string>>(new Map());
const [createScenario, isCreating] = useMutation<CreateScenarioInScopeDialogMutation>(createScenarioMutation);
const [linkThreat] = useMutation<CreateScenarioInScopeDialogLinkThreatMutation>(linkThreatMutation);
const [linkRisk] = useMutation<CreateScenarioInScopeDialogLinkRiskMutation>(linkRiskMutation);
const { register, handleSubmit, reset, formState } = useForm({
defaultValues: { name: "", description: "" },
});
@@ -96,8 +165,16 @@ export function CreateScenarioInScopeDialog(props: {
},
});
}
for (const riskId of selectedRisks.keys()) {
linkRisk({
variables: {
input: { riskAssessmentScenarioId: scenarioId, riskId },
},
});
}
reset();
setSelectedThreats(new Map());
setSelectedRisks(new Map());
dialogRef.current?.close();
},
});
@@ -152,6 +229,7 @@ export function CreateScenarioInScopeDialog(props: {
)}
{availableThreats.length > 0 && (
<Select
key={selectedThreats.size}
placeholder={__("Select a threat to link...")}
onValueChange={(threatId) => {
if (typeof threatId !== "string") return;
@@ -172,6 +250,44 @@ export function CreateScenarioInScopeDialog(props: {
)}
</div>
)}
<div>
<div className="text-sm font-medium mb-2">{__("Risks")}</div>
{selectedRisks.size > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{[...selectedRisks.entries()].map(([id, name]) => (
<Badge key={id}>
{name}
<button
type="button"
className="ml-1 hover:text-txt-danger"
onClick={() => {
setSelectedRisks((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
}}
>
<IconCrossLargeX size={12} />
</button>
</Badge>
))}
</div>
)}
<Suspense fallback={<p className="text-xs text-txt-tertiary">{__("Loading risks...")}</p>}>
<RiskSelector
selectedRisks={selectedRisks}
onSelect={(id, name) => {
setSelectedRisks((prev) => {
const next = new Map(prev);
next.set(id, name);
return next;
});
}}
/>
</Suspense>
</div>
</DialogContent>
<DialogFooter><Button type="submit" disabled={isCreating}>{__("Add")}</Button></DialogFooter>
</form>

View File

@@ -40,6 +40,7 @@ import { NodeActions } from "./NodeActions";
import { ProcessActions } from "./ProcessActions";
import { ScenarioInScopeActions } from "./ScenarioInScopeActions";
import { ScopeActions } from "./ScopeActions";
import { ScopeDiagram } from "./ScopeDiagram";
import { ThreatActions } from "./ThreatActions";
export const scopeCardFragment = graphql`
@@ -82,6 +83,7 @@ export const scopeCardFragment = graphql`
}
}
}
...ScopeDiagram_scope
}
`;
@@ -164,6 +166,16 @@ export function ScopeCard(props: {
{isOpen && (
<div className="border-t border-border-low px-4 py-4 space-y-6">
<div>
<div className="mb-3">
<h3 className="text-sm font-semibold">{__("Diagram")}</h3>
<p className="text-xs text-txt-tertiary mt-1">
{__("Visualization of nodes, processes, and threats in this scope.")}
</p>
</div>
<ScopeDiagram scopeKey={scope} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<SectionHeader

View File

@@ -0,0 +1,282 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import { useTranslate } from "@probo/i18n";
import { Button, MermaidDiagram, useToast } from "@probo/ui";
import { useEffect, useRef, useState } from "react";
import { fetchQuery, graphql, useFragment, useRelayEnvironment } from "react-relay";
import type { ScopeDiagram_scope$key } from "#/__generated__/core/ScopeDiagram_scope.graphql";
import type { ScopeDiagramMermaidQuery } from "#/__generated__/core/ScopeDiagramMermaidQuery.graphql";
const scopeDiagramFragment = graphql`
fragment ScopeDiagram_scope on RiskAssessmentScope {
id
mermaidChart
nodes(first: 100)
@connection(key: "RiskAssessmentScope_nodes", filters: []) {
edges {
node {
id
name
nodeType
}
}
}
processes(first: 100)
@connection(key: "RiskAssessmentScope_processes", filters: []) {
edges {
node {
id
name
sourceNodeId
targetNodeId
}
}
}
threats(first: 100)
@connection(key: "RiskAssessmentScope_threats", filters: []) {
edges {
node {
id
name
processId
category
}
}
}
}
`;
const scopeDiagramMermaidQuery = graphql`
query ScopeDiagramMermaidQuery($scopeId: ID!) {
node(id: $scopeId) {
... on RiskAssessmentScope {
id
mermaidChart
}
}
}
`;
interface ScopeDiagramProps {
scopeKey: ScopeDiagram_scope$key;
}
export function ScopeDiagram({ scopeKey }: ScopeDiagramProps) {
const { __ } = useTranslate();
const environment = useRelayEnvironment();
const scope = useFragment(scopeDiagramFragment, scopeKey);
const mermaidChart = scope.mermaidChart;
const nodeSignature = scope.nodes?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.nodeType}`)
.join(";") ?? "";
const processSignature = scope.processes?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.sourceNodeId}|${e.node.targetNodeId}`)
.join(";") ?? "";
const threatSignature = scope.threats?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.processId}|${e.node.category}`)
.join(";") ?? "";
const signature = `${nodeSignature}::${processSignature}::${threatSignature}`;
const previousSignature = useRef(signature);
useEffect(() => {
if (previousSignature.current === signature) {
return;
}
previousSignature.current = signature;
const subscription = fetchQuery<ScopeDiagramMermaidQuery>(
environment,
scopeDiagramMermaidQuery,
{ scopeId: scope.id },
{ fetchPolicy: "network-only" },
).subscribe({});
return () => subscription.unsubscribe();
}, [signature, environment, scope.id]);
if (!mermaidChart) {
return (
<div className="text-center text-txt-secondary text-sm py-6">
{__("Add nodes and processes to see the diagram.")}
</div>
);
}
return (
<div className="relative">
<div className="absolute right-0 top-0 z-10">
<CopyChartButton chart={mermaidChart} />
</div>
<div className="overflow-x-auto">
<MermaidDiagram chart={mermaidChart} />
<Legend />
</div>
</div>
);
}
interface CopyChartButtonProps {
chart: string;
}
function CopyChartButton({ chart }: CopyChartButtonProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const onClick = () => {
const onFailure = () => {
toast({
title: __("Error"),
description: __("Failed to copy to clipboard"),
variant: "error",
});
};
if (!navigator.clipboard?.writeText) {
onFailure();
return;
}
try {
navigator.clipboard.writeText(chart).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
onFailure,
);
} catch {
onFailure();
}
};
return (
<Button
variant="secondary"
icon={copied ? CheckIcon : CopyIcon}
onClick={onClick}
aria-label={__("Copy mermaid source")}
title={__("Copy mermaid source")}
/>
);
}
type LegendShape = "stadium" | "hexagon" | "rectangle" | "cylinder";
type LegendItem = {
label: string;
shape: LegendShape;
fill: string;
stroke: string;
text: string;
};
function Legend() {
const { __ } = useTranslate();
const items: LegendItem[] = [
{ label: __("Entity"), shape: "stadium", fill: "#dbeafe", stroke: "#1d4ed8", text: "#1e3a8a" },
{ label: __("Boundary"), shape: "hexagon", fill: "#fef3c7", stroke: "#b45309", text: "#78350f" },
{ label: __("Asset"), shape: "rectangle", fill: "#e5e7eb", stroke: "#374151", text: "#111827" },
{ label: __("Data"), shape: "cylinder", fill: "#dcfce7", stroke: "#15803d", text: "#14532d" },
{ label: __("Threat"), shape: "hexagon", fill: "#fee2e2", stroke: "#b91c1c", text: "#7f1d1d" },
];
return (
<div className="flex flex-wrap items-center gap-3 mt-3">
{items.map(item => (
<LegendSwatch key={item.label} item={item} />
))}
</div>
);
}
interface LegendSwatchProps {
item: LegendItem;
}
function LegendSwatch({ item }: LegendSwatchProps) {
const w = 88;
const h = 28;
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-label={item.label}
>
<LegendShapeEl shape={item.shape} w={w} h={h} fill={item.fill} stroke={item.stroke} />
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontFamily="inherit"
fill={item.text}
>
{item.label}
</text>
</svg>
);
}
interface LegendShapeElProps {
shape: LegendShape;
w: number;
h: number;
fill: string;
stroke: string;
}
function LegendShapeEl({ shape, w, h, fill, stroke }: LegendShapeElProps) {
const sw = 1.5;
const common = { fill, stroke, strokeWidth: sw };
switch (shape) {
case "stadium":
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} rx={(h - sw) / 2} {...common} />;
case "rectangle":
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} {...common} />;
case "hexagon": {
const inset = 8;
const pts = [
`${inset},${h / 2}`,
`${inset + 4},${sw}`,
`${w - inset - 4},${sw}`,
`${w - inset},${h / 2}`,
`${w - inset - 4},${h - sw}`,
`${inset + 4},${h - sw}`,
].join(" ");
return <polygon points={pts} {...common} />;
}
case "cylinder": {
const ry = 4;
return (
<g>
<path
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${w - sw / 2} ${ry + sw / 2} L ${w - sw / 2} ${h - ry - sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${sw / 2} ${h - ry - sw / 2} Z`}
{...common}
/>
<path
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 0 ${w - sw / 2} ${ry + sw / 2}`}
fill="none"
stroke={stroke}
strokeWidth={sw}
/>
</g>
);
}
}
}