@brignano/driftwood 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -31
- package/dist/cli.js +2 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/providers/terraform.js +11 -1
- package/dist/render/dot.js +60 -24
- package/dist/render/graphviz.d.ts +10 -1
- package/dist/render/graphviz.js +26 -5
- package/dist/render/icons.d.ts +74 -0
- package/dist/render/icons.js +306 -0
- package/dist/render/mermaid.js +39 -26
- package/dist/render/types.d.ts +6 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ Nobody owns the middle. driftwood is the middle.
|
|
|
23
23
|
|
|
24
24
|
| Engine | Needs | Output |
|
|
25
25
|
|---|---|---|
|
|
26
|
-
| `graphviz` (native) | `dot` on PATH | SVG. Preferred when present — faster on very large graphs, honours a site's own Graphviz build |
|
|
27
|
-
| `graphviz` (WASM) | **nothing — bundled** | SVG. The default |
|
|
26
|
+
| `graphviz` (native) | `dot` on PATH | SVG, icons and all. Preferred when present — faster on very large graphs, honours a site's own Graphviz build |
|
|
27
|
+
| `graphviz` (WASM) | **nothing — bundled** | SVG with category icons. The default |
|
|
28
28
|
| `dot` | nothing | DOT source (it's just text) |
|
|
29
29
|
| `mermaid` | nothing | Mermaid, renders natively in GitHub |
|
|
30
30
|
|
|
@@ -71,13 +71,15 @@ flowchart LR
|
|
|
71
71
|
REC["Reconciler<br/>declared vs observed"]
|
|
72
72
|
end
|
|
73
73
|
subgraph Renderers["Renderers — present"]
|
|
74
|
-
|
|
74
|
+
GV["Graphviz SVG"]
|
|
75
|
+
MMD["Mermaid · DOT"]
|
|
75
76
|
LATER["2D live · 3D<br/>(not yet)"]
|
|
76
77
|
end
|
|
77
78
|
TF --> REC
|
|
78
79
|
SOON -.-> REC
|
|
79
80
|
MODEL --> REC
|
|
80
81
|
REC -->|"divergence"| MODEL
|
|
82
|
+
MODEL --> GV
|
|
81
83
|
MODEL --> MMD
|
|
82
84
|
MODEL -.-> LATER
|
|
83
85
|
```
|
|
@@ -98,11 +100,13 @@ Requires Node 20+. Runtime dependencies are `commander`, `yaml`, `zod`, and `@hp
|
|
|
98
100
|
### Import a model from Terraform state
|
|
99
101
|
|
|
100
102
|
```bash
|
|
101
|
-
npx tsx src/cli.ts import terraform examples/
|
|
102
|
-
--name
|
|
103
|
+
npx tsx src/cli.ts import terraform examples/orders-platform.tfstate.json \
|
|
104
|
+
--name orders-platform -o examples/architecture.yaml
|
|
103
105
|
```
|
|
104
106
|
|
|
105
|
-
Entity ids are Terraform addresses (`aws_s3_bucket.
|
|
107
|
+
Entity ids are Terraform addresses (`aws_s3_bucket.assets`). That's deliberate: the address is stable across plans, readable in a diff, and sidesteps the identity-resolution problem that kills CMDBs. Edges come from Terraform's own `dependencies`.
|
|
108
|
+
|
|
109
|
+
The worked example in `examples/` is a fictional e-commerce platform — CloudFront and Route 53 at the edge, an ALB in front of two ECS services, Postgres, Redis, DynamoDB and S3 behind them, and an SQS/SNS order pipeline with a Lambda worker. 45 resources: enough that the view mechanism is doing real work rather than decorating a diagram that fits on a page anyway.
|
|
106
110
|
|
|
107
111
|
### Validate
|
|
108
112
|
|
|
@@ -115,54 +119,103 @@ Catches schema errors, duplicate ids, edges pointing at entities that don't exis
|
|
|
115
119
|
### Render
|
|
116
120
|
|
|
117
121
|
```bash
|
|
118
|
-
npx tsx src/cli.ts render examples/architecture.yaml --view
|
|
122
|
+
npx tsx src/cli.ts render examples/architecture.yaml --view app -o app.svg
|
|
119
123
|
```
|
|
120
124
|
|
|
125
|
+

|
|
126
|
+
|
|
127
|
+
Nodes are drawn with a category icon, the entity name, and its `kind` underneath. The icons are **drawn in this repo and chosen by category** — database, queue, load balancer — not by vendor: no icon pack to install, nothing to license, and one glyph that fits an RDS instance, a Cloud SQL instance and an on-prem Postgres alike. Colour groups the categories into families (edge, compute, data, messaging, security), so a diagram reads as a few zones instead of thirty unrelated boxes. Use `--no-icons` for plain boxes.
|
|
128
|
+
|
|
129
|
+
The icons are inlined into the SVG rather than handed to Graphviz as `image=` attributes, because the native `dot` binary resolves those as filesystem paths — a `data:` URI would work on the WASM tier and break on the native one. Post-processing the SVG means both tiers draw the same picture, and the result stays self-contained — no external references, no base64 bloat.
|
|
130
|
+
|
|
131
|
+
Every image in this README is generated from `examples/architecture.yaml` by `npm run docs`, and CI re-runs it and fails if the result differs from what is committed. A stale picture of your own output is worse than no picture, so the renders are checked against their source rather than trusted — the drift gate's own argument, one level up.
|
|
132
|
+
|
|
133
|
+
The `async` view through Mermaid, which renders natively in a pull request:
|
|
134
|
+
|
|
135
|
+
<!-- generated:async -->
|
|
121
136
|
```mermaid
|
|
122
137
|
flowchart LR
|
|
123
138
|
subgraph n_lambda["lambda"]
|
|
124
|
-
|
|
139
|
+
n_aws_lambda_event_source_mapping_orders[/"orders<br/>aws_lambda_event_source_mapping"/]
|
|
140
|
+
n_aws_lambda_function_order_worker["shop-order-worker<br/>aws_lambda_function"]
|
|
125
141
|
end
|
|
126
|
-
subgraph
|
|
127
|
-
|
|
142
|
+
subgraph n_sns["sns"]
|
|
143
|
+
n_aws_sns_topic_subscription_orders[/"orders<br/>aws_sns_topic_subscription"/]
|
|
144
|
+
n_aws_sns_topic_order_events[/"shop-order-events<br/>aws_sns_topic"/]
|
|
128
145
|
end
|
|
129
|
-
subgraph
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
n_aws_ses_receipt_rule_forward["forward-hi<br/>aws_ses_receipt_rule"]
|
|
133
|
-
n_aws_ses_receipt_rule_noreply["bounce-noreply<br/>aws_ses_receipt_rule"]
|
|
146
|
+
subgraph n_sqs["sqs"]
|
|
147
|
+
n_aws_sqs_queue_orders[/"shop-orders<br/>aws_sqs_queue"/]
|
|
148
|
+
n_aws_sqs_queue_orders_dlq[/"shop-orders-dlq<br/>aws_sqs_queue"/]
|
|
134
149
|
end
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
n_aws_lambda_event_source_mapping_orders --> n_aws_lambda_function_order_worker
|
|
151
|
+
n_aws_lambda_event_source_mapping_orders --> n_aws_sqs_queue_orders
|
|
152
|
+
n_aws_lambda_function_order_worker --> n_aws_sqs_queue_orders
|
|
153
|
+
n_aws_sns_topic_subscription_orders --> n_aws_sns_topic_order_events
|
|
154
|
+
n_aws_sns_topic_subscription_orders --> n_aws_sqs_queue_orders
|
|
155
|
+
n_aws_sqs_queue_orders --> n_aws_sqs_queue_orders_dlq
|
|
156
|
+
class n_aws_lambda_event_source_mapping_orders f_messaging;
|
|
157
|
+
class n_aws_lambda_function_order_worker f_compute;
|
|
158
|
+
class n_aws_sns_topic_subscription_orders f_messaging;
|
|
159
|
+
class n_aws_sns_topic_order_events f_messaging;
|
|
160
|
+
class n_aws_sqs_queue_orders f_messaging;
|
|
161
|
+
class n_aws_sqs_queue_orders_dlq f_messaging;
|
|
162
|
+
classDef f_compute fill:#fffbeb,stroke:#d97706,color:#0f172a;
|
|
163
|
+
classDef f_messaging fill:#f5f3ff,stroke:#7c3aed,color:#0f172a;
|
|
141
164
|
```
|
|
165
|
+
<!-- /generated:async -->
|
|
166
|
+
|
|
167
|
+
Mermaid has no icon primitive, so it maps the *same* categorisation onto shapes and colours — a queue must not be a queue in one engine and a cylinder in the other, or the two pictures stop describing the same system.
|
|
168
|
+
|
|
169
|
+
#### The rest of the views
|
|
170
|
+
|
|
171
|
+
| View | What it scopes to | File |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `edge` | Route 53, CloudFront, ACM, WAF, the ALB | [svg](docs/edge.svg) |
|
|
174
|
+
| `app` | Load balancer through to the ECS services | [svg](docs/app.svg) |
|
|
175
|
+
| `data` | Postgres, Redis, DynamoDB, S3 | [svg](docs/data.svg) |
|
|
176
|
+
| `async` | The SQS/SNS/Lambda order pipeline | [svg](docs/async.svg) |
|
|
177
|
+
| `network` | VPC, subnets, security groups, NAT | [svg](docs/network.svg) |
|
|
178
|
+
| `context` | Everything, minus IAM and CloudWatch noise | [svg](docs/context.svg) |
|
|
142
179
|
|
|
143
|
-
**Views exist from v1, not as a later optimization.** Flat Mermaid becomes unreadable past roughly 150 nodes, and any real enterprise graph blows through that immediately. A view is a scoped slice matching entity ids or groups, with a trailing `*` wildcard.
|
|
180
|
+
**Views exist from v1, not as a later optimization.** Flat Mermaid becomes unreadable past roughly 150 nodes, and any real enterprise graph blows through that immediately. A view is a scoped slice matching entity ids or groups, with a trailing `*` wildcard. The example model ships six — `context`, `edge`, `app`, `data`, `async`, `network` — and even at 45 entities the difference between a view and the whole graph is the difference between a diagram and a wall.
|
|
144
181
|
|
|
145
182
|
### Reconcile — the point of the whole thing
|
|
146
183
|
|
|
147
184
|
```bash
|
|
148
185
|
npx tsx src/cli.ts reconcile examples/architecture.yaml \
|
|
149
|
-
--terraform examples/
|
|
186
|
+
--terraform examples/orders-platform.drifted.tfstate.json
|
|
150
187
|
```
|
|
151
188
|
|
|
189
|
+
Two resources created by hand during an incident, one bucket deleted, one database renamed:
|
|
190
|
+
|
|
152
191
|
```markdown
|
|
153
192
|
## Architecture drift detected
|
|
154
193
|
|
|
155
194
|
### Present in infrastructure, missing from the model (2)
|
|
156
|
-
|
|
157
|
-
- `
|
|
195
|
+
|
|
196
|
+
- `aws_elasticache_replication_group.sessions_failover` - aws_elasticache_replication_group (shop-sessions-ha)
|
|
197
|
+
- `aws_sqs_queue.payments` - aws_sqs_queue (shop-payments)
|
|
158
198
|
|
|
159
199
|
### Declared in the model, not found in infrastructure (1)
|
|
160
|
-
|
|
200
|
+
|
|
201
|
+
- `aws_s3_bucket.uploads` - aws_s3_bucket (shop-example-com-uploads)
|
|
202
|
+
|
|
203
|
+
### Changed (1)
|
|
204
|
+
|
|
205
|
+
| Entity | Field | Declared | Observed |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| `aws_db_instance.orders` | name | shop-orders-prod | shop-orders-prod-v2 |
|
|
161
208
|
|
|
162
209
|
### Relationships
|
|
163
|
-
|
|
164
|
-
- **added** `
|
|
165
|
-
- **
|
|
210
|
+
|
|
211
|
+
- **added** `aws_elasticache_replication_group.sessions_failover` -> `aws_security_group.data`
|
|
212
|
+
- **added** `aws_elasticache_replication_group.sessions_failover` -> `aws_subnet.private_a`
|
|
213
|
+
- **added** `aws_sqs_queue.payments` -> `aws_kms_key.data`
|
|
214
|
+
- **removed** `aws_iam_role_policy.order_worker` -> `aws_s3_bucket.uploads`
|
|
215
|
+
- **removed** `aws_lambda_function.order_worker` -> `aws_s3_bucket.uploads`
|
|
216
|
+
- **removed** `aws_s3_bucket.uploads` -> `aws_kms_key.data`
|
|
217
|
+
|
|
218
|
+
_Ignored by policy: 1 entities, 0 edges._
|
|
166
219
|
```
|
|
167
220
|
|
|
168
221
|
Exits **1** on drift and **0** when clean, so it works directly as a CI gate. Output is markdown because its destination is a pull request body.
|
|
@@ -264,20 +317,23 @@ src/
|
|
|
264
317
|
providers/dynatrace.ts runtime — Smartscape topology, read-only
|
|
265
318
|
render/types.ts the renderer extension point (probe + render)
|
|
266
319
|
render/select.ts shared view scoping
|
|
320
|
+
render/icons.ts category icons, the kind -> icon/colour table, SVG injection
|
|
267
321
|
render/mermaid.ts always available
|
|
268
322
|
render/dot.ts DOT source, always available
|
|
269
323
|
render/graphviz.ts SVG via native dot or WASM, with tier detection
|
|
270
324
|
reconcile/index.ts declared vs observed -> drift report
|
|
271
325
|
config.ts driftwood.config.yaml
|
|
272
326
|
cli.ts validate · render · engines · providers · import · reconcile
|
|
273
|
-
examples/ a worked AWS
|
|
327
|
+
examples/ a worked 45-resource AWS platform, a drifted copy, and a config
|
|
328
|
+
scripts/render-docs.ts regenerates docs/ and the README's embedded render
|
|
329
|
+
docs/ committed renders of the example, kept current by CI
|
|
274
330
|
.claude/skills/ add-provider and add-renderer walkthroughs for agents
|
|
275
331
|
```
|
|
276
332
|
|
|
277
333
|
## Development
|
|
278
334
|
|
|
279
335
|
```bash
|
|
280
|
-
npm test #
|
|
336
|
+
npm test # 98 tests
|
|
281
337
|
npm run typecheck
|
|
282
338
|
npm run build
|
|
283
339
|
```
|
|
@@ -289,6 +345,7 @@ Built:
|
|
|
289
345
|
- [x] The model, with a schema and a real validator
|
|
290
346
|
- [x] Pluggable provider registry — Terraform (any platform) and Dynatrace built in
|
|
291
347
|
- [x] Pluggable renderer registry — Graphviz bundled and working out of the box, with Mermaid/DOT fallback
|
|
348
|
+
- [x] Category icons and a family palette, shared by every engine
|
|
292
349
|
- [x] Multi-provider merge with provenance, explicit aliases, and conflict reporting
|
|
293
350
|
- [x] Declarative `driftwood.config.yaml` wiring
|
|
294
351
|
- [x] Reconciler with an explicit drift policy, wired as a CI gate
|
package/dist/cli.js
CHANGED
|
@@ -38,11 +38,12 @@ program
|
|
|
38
38
|
.option('--view <id>', 'render a single named view')
|
|
39
39
|
.option('--engine <name>', 'auto | mermaid | dot | graphviz', 'auto')
|
|
40
40
|
.option('--direction <dir>', 'LR or TD', 'LR')
|
|
41
|
+
.option('--no-icons', 'draw plain boxes instead of category icons')
|
|
41
42
|
.option('-o, --out <file>', 'write to a file instead of stdout')
|
|
42
43
|
.action(async (path, opts) => {
|
|
43
44
|
const model = requireModel(path);
|
|
44
45
|
const direction = opts.direction === 'TD' ? 'TD' : 'LR';
|
|
45
|
-
const result = await render(model, { view: opts.view, direction }, opts.engine);
|
|
46
|
+
const result = await render(model, { view: opts.view, direction, icons: opts.icons }, opts.engine);
|
|
46
47
|
if (result.fellBackFrom) {
|
|
47
48
|
console.error(`note: ${result.fellBackFrom} unavailable, using ${result.renderer.name} (${result.via})`);
|
|
48
49
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export { importTerraformState, parseTerraformState } from './providers/terraform
|
|
|
13
13
|
export type { ImportOptions } from './providers/terraform.js';
|
|
14
14
|
export { toModel as dynatraceToModel } from './providers/dynatrace.js';
|
|
15
15
|
export { renderMermaid } from './render/mermaid.js';
|
|
16
|
+
export { iconFor, familyFor, styleFor, iconSvg, ICONS, PALETTE, ICON_SIZE } from './render/icons.js';
|
|
17
|
+
export type { IconKey, Family, FamilyStyle } from './render/icons.js';
|
|
16
18
|
export { renderDot } from './render/dot.js';
|
|
17
19
|
export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
|
|
18
20
|
export { selectEntities } from './render/select.js';
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,12 @@ export { importTerraformState, parseTerraformState } from './providers/terraform
|
|
|
12
12
|
export { toModel as dynatraceToModel } from './providers/dynatrace.js';
|
|
13
13
|
// Renderers
|
|
14
14
|
export { renderMermaid } from './render/mermaid.js';
|
|
15
|
+
// How an entity is classified and coloured. A third-party renderer is told to
|
|
16
|
+
// classify through this rather than inventing a second table, so it has to be
|
|
17
|
+
// reachable from the package entry point, not just from inside the repo.
|
|
18
|
+
// The marker/injection mechanism stays internal: it is a private contract
|
|
19
|
+
// between `renderDot` and the Graphviz renderer, not API to build on.
|
|
20
|
+
export { iconFor, familyFor, styleFor, iconSvg, ICONS, PALETTE, ICON_SIZE } from './render/icons.js';
|
|
15
21
|
export { renderDot } from './render/dot.js';
|
|
16
22
|
export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
|
|
17
23
|
export { selectEntities } from './render/select.js';
|
|
@@ -29,7 +29,17 @@ function groupFor(type) {
|
|
|
29
29
|
* resource's local name.
|
|
30
30
|
*/
|
|
31
31
|
function displayName(r, attrs) {
|
|
32
|
-
const candidates = [
|
|
32
|
+
const candidates = [
|
|
33
|
+
'name',
|
|
34
|
+
'bucket',
|
|
35
|
+
'domain_name',
|
|
36
|
+
'function_name',
|
|
37
|
+
'identifier',
|
|
38
|
+
'cluster_id',
|
|
39
|
+
'replication_group_id',
|
|
40
|
+
'alarm_name',
|
|
41
|
+
'family',
|
|
42
|
+
];
|
|
33
43
|
for (const key of candidates) {
|
|
34
44
|
const v = attrs?.[key];
|
|
35
45
|
if (typeof v === 'string' && v.length > 0)
|
package/dist/render/dot.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineRenderer } from './types.js';
|
|
2
2
|
import { selectEntities } from './select.js';
|
|
3
|
+
import { ICON_SIZE, MARKER_PREFIX, iconFor, marker, styleFor } from './icons.js';
|
|
3
4
|
/**
|
|
4
5
|
* Graphviz DOT source.
|
|
5
6
|
*
|
|
@@ -7,39 +8,61 @@ import { selectEntities } from './select.js';
|
|
|
7
8
|
* matters: `dot` (this renderer) always works, while `graphviz` (rendering DOT
|
|
8
9
|
* to SVG) needs an engine. So even a locked-down machine can produce DOT for
|
|
9
10
|
* someone else to render, and nothing is lost by not having the binary.
|
|
11
|
+
*
|
|
12
|
+
* Nodes are HTML-like labels rather than plain `label="a\nb"` strings for two
|
|
13
|
+
* reasons. It allows a name/kind type hierarchy — one line at reading size and
|
|
14
|
+
* one small and muted — instead of two lines competing at the same weight; and
|
|
15
|
+
* it allows a fixed-size cell to be reserved for an icon (see `icons.ts`),
|
|
16
|
+
* which Graphviz then accounts for during layout. Both are what stop nodes
|
|
17
|
+
* coming out as long flat slabs: a box holding an icon above two short lines
|
|
18
|
+
* is roughly 3:2, while one holding a single wide line of text is closer to
|
|
19
|
+
* 6:1, and a graph full of 6:1 boxes is what reads as "squashed".
|
|
10
20
|
*/
|
|
21
|
+
const INK = '#0f172a';
|
|
22
|
+
const MUTED = '#64748b';
|
|
23
|
+
const CLUSTER_LINE = '#dbe2ea';
|
|
24
|
+
const CLUSTER_FILL = '#f8fafc';
|
|
25
|
+
const EDGE_COLOR = '#94a3b8';
|
|
26
|
+
/** Border and fill applied on top of the family colours when health is known. */
|
|
27
|
+
const HEALTH_COLORS = {
|
|
28
|
+
healthy: { accent: '#16a34a', tint: '#f0fdf4' },
|
|
29
|
+
degraded: { accent: '#d97706', tint: '#fffbeb' },
|
|
30
|
+
down: { accent: '#dc2626', tint: '#fef2f2' },
|
|
31
|
+
};
|
|
11
32
|
function quote(text) {
|
|
12
33
|
return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
13
34
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
/**
|
|
36
|
+
* HTML-like labels are parsed as XML, so the five entity references matter —
|
|
37
|
+
* an unescaped `&` in a name is a hard parse error, not a cosmetic problem.
|
|
38
|
+
* The icon marker prefix is stripped here too: a model is data, and data must
|
|
39
|
+
* not be able to place its own artwork in the output.
|
|
40
|
+
*/
|
|
41
|
+
function xml(text) {
|
|
42
|
+
return text
|
|
43
|
+
.split(MARKER_PREFIX)
|
|
44
|
+
.join('')
|
|
45
|
+
.replace(/&/g, '&')
|
|
46
|
+
.replace(/</g, '<')
|
|
47
|
+
.replace(/>/g, '>')
|
|
48
|
+
.replace(/"/g, '"');
|
|
25
49
|
}
|
|
26
|
-
const HEALTH_COLORS = {
|
|
27
|
-
healthy: '#0f9d58',
|
|
28
|
-
degraded: '#f4b400',
|
|
29
|
-
down: '#db4437',
|
|
30
|
-
};
|
|
31
50
|
export function renderDot(model, ctx = {}) {
|
|
32
51
|
const view = ctx.view ? model.views.find((v) => v.id === ctx.view) : undefined;
|
|
33
52
|
if (ctx.view && !view) {
|
|
34
53
|
throw new Error(`unknown view: ${ctx.view} (have: ${model.views.map((v) => v.id).join(', ') || 'none'})`);
|
|
35
54
|
}
|
|
55
|
+
// Off by default: DOT source is meant to be renderable by any Graphviz, and
|
|
56
|
+
// a marker only becomes artwork if driftwood post-processes the SVG itself.
|
|
57
|
+
const withIcons = ctx.icons === true;
|
|
36
58
|
const entities = selectEntities(model, view);
|
|
37
59
|
const visible = new Set(entities.map((e) => e.id));
|
|
38
60
|
const lines = [
|
|
39
61
|
`digraph "${quote(model.name)}" {`,
|
|
40
|
-
` rankdir=${ctx.direction === 'TD' ? 'TB' : 'LR'}
|
|
41
|
-
'
|
|
42
|
-
'
|
|
62
|
+
` graph [rankdir=${ctx.direction === 'TD' ? 'TB' : 'LR'} fontname="Helvetica" fontsize=11 bgcolor="white"`,
|
|
63
|
+
' nodesep=0.35 ranksep=0.75 pad=0.3 newrank=true];',
|
|
64
|
+
' node [shape=box style="rounded,filled" fontname="Helvetica" margin=0.06 penwidth=1.2];',
|
|
65
|
+
` edge [fontname="Helvetica" fontsize=9 fontcolor="${MUTED}" color="${EDGE_COLOR}" penwidth=1.1 arrowsize=0.7];`,
|
|
43
66
|
];
|
|
44
67
|
const grouped = new Map();
|
|
45
68
|
const ungrouped = [];
|
|
@@ -53,16 +76,29 @@ export function renderDot(model, ctx = {}) {
|
|
|
53
76
|
ungrouped.push(e);
|
|
54
77
|
}
|
|
55
78
|
const nodeLine = (e, indent) => {
|
|
56
|
-
const label = `${e.name ?? e.id}\\n${e.kind}`;
|
|
57
79
|
const health = ctx.health?.[e.id];
|
|
58
|
-
const
|
|
59
|
-
|
|
80
|
+
const style = (health ? HEALTH_COLORS[health] : undefined) ?? styleFor(e.kind);
|
|
81
|
+
const penwidth = health ? 2.2 : 1.2;
|
|
82
|
+
const rows = [];
|
|
83
|
+
if (withIcons) {
|
|
84
|
+
// A fixed-size cell so Graphviz reserves exactly the room the icon will
|
|
85
|
+
// occupy; the 1pt marker inside it is what `injectIcons` finds and
|
|
86
|
+
// replaces, and is invisible if it somehow survives.
|
|
87
|
+
rows.push(`<TR><TD FIXEDSIZE="TRUE" WIDTH="${ICON_SIZE + 10}" HEIGHT="${ICON_SIZE + 6}">` +
|
|
88
|
+
`<FONT POINT-SIZE="1" COLOR="${CLUSTER_FILL}">${marker(iconFor(e.kind))}</FONT></TD></TR>`);
|
|
89
|
+
}
|
|
90
|
+
rows.push(`<TR><TD><FONT POINT-SIZE="11" COLOR="${INK}"><B>${xml(e.name ?? e.id)}</B></FONT></TD></TR>`);
|
|
91
|
+
rows.push(`<TR><TD><FONT POINT-SIZE="8" COLOR="${MUTED}">${xml(e.kind)}</FONT></TD></TR>`);
|
|
92
|
+
const label = `<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="1">${rows.join('')}</TABLE>>`;
|
|
93
|
+
return (`${indent}"${quote(e.id)}" [label=${label} fillcolor="${style.tint}" ` +
|
|
94
|
+
`color="${style.accent}" penwidth=${penwidth}];`);
|
|
60
95
|
};
|
|
61
96
|
let clusterIndex = 0;
|
|
62
97
|
for (const [group, members] of [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
63
98
|
lines.push(` subgraph cluster_${clusterIndex++} {`);
|
|
64
|
-
lines.push(` label="${
|
|
65
|
-
lines.push(
|
|
99
|
+
lines.push(` label=<<FONT POINT-SIZE="11" COLOR="${MUTED}">${xml(group)}</FONT>>;`);
|
|
100
|
+
lines.push(` style="rounded,filled"; fillcolor="${CLUSTER_FILL}"; color="${CLUSTER_LINE}";`);
|
|
101
|
+
lines.push(' penwidth=1; labeljust=l; margin=14;');
|
|
66
102
|
for (const e of members)
|
|
67
103
|
lines.push(nodeLine(e, ' '));
|
|
68
104
|
lines.push(' }');
|
|
@@ -42,6 +42,15 @@ export declare function loadWasmGraphviz(): Promise<WasmGraphviz | undefined>;
|
|
|
42
42
|
/** Test seam: forget any cached WASM instance. */
|
|
43
43
|
export declare function resetWasmCache(): void;
|
|
44
44
|
export declare function detectTier(): Promise<GraphvizTier>;
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* @param forceTier Pin the tier instead of detecting one. The committed
|
|
47
|
+
* example renders use this: layout is byte-deterministic for a given Graphviz
|
|
48
|
+
* build, but a native `dot` and the bundled WASM build are two different
|
|
49
|
+
* builds, so a contributor with Graphviz installed would otherwise regenerate
|
|
50
|
+
* `docs/` into a diff that fails the freshness check in CI, where no `dot`
|
|
51
|
+
* exists. Pinning is for reproducibility only — nothing in normal rendering
|
|
52
|
+
* uses it, and `auto` must keep choosing for itself.
|
|
53
|
+
*/
|
|
54
|
+
export declare function renderGraphvizSvg(model: Model, ctx?: RenderContext, forceTier?: Exclude<GraphvizTier, 'none'>): Promise<string>;
|
|
46
55
|
export declare const graphvizRenderer: import("./types.js").Renderer;
|
|
47
56
|
export {};
|
package/dist/render/graphviz.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { renderDot } from './dot.js';
|
|
3
|
+
import { PALETTE, injectIcons, styleFor } from './icons.js';
|
|
3
4
|
import { defineRenderer } from './types.js';
|
|
4
5
|
/** Runs `dot -V` to see whether a usable native Graphviz is on PATH. */
|
|
5
6
|
export function probeNativeDot(command = 'dot') {
|
|
@@ -82,15 +83,35 @@ function runNativeDot(source, engine, format) {
|
|
|
82
83
|
child.stdin.end();
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
/**
|
|
87
|
+
* @param forceTier Pin the tier instead of detecting one. The committed
|
|
88
|
+
* example renders use this: layout is byte-deterministic for a given Graphviz
|
|
89
|
+
* build, but a native `dot` and the bundled WASM build are two different
|
|
90
|
+
* builds, so a contributor with Graphviz installed would otherwise regenerate
|
|
91
|
+
* `docs/` into a diff that fails the freshness check in CI, where no `dot`
|
|
92
|
+
* exists. Pinning is for reproducibility only — nothing in normal rendering
|
|
93
|
+
* uses it, and `auto` must keep choosing for itself.
|
|
94
|
+
*/
|
|
95
|
+
export async function renderGraphvizSvg(model, ctx = {}, forceTier) {
|
|
96
|
+
// Icons are on unless switched off: this renderer produces the finished
|
|
97
|
+
// picture, so it is the one place a marker can actually become artwork.
|
|
98
|
+
const withIcons = ctx.icons !== false;
|
|
99
|
+
const source = renderDot(model, { ...ctx, icons: withIcons });
|
|
100
|
+
// Colour by the entity's own kind rather than by anything in the SVG, so the
|
|
101
|
+
// icon always matches the border Graphviz drew around it.
|
|
102
|
+
const accents = new Map(model.entities.map((e) => [e.id, styleFor(e.kind).accent]));
|
|
103
|
+
const finish = (svg) => withIcons ? injectIcons(svg, (id) => accents.get(id) ?? PALETTE.other.accent) : svg;
|
|
104
|
+
const tier = forceTier ?? (await detectTier());
|
|
88
105
|
if (tier === 'native')
|
|
89
|
-
return runNativeDot(source, 'dot', 'svg');
|
|
106
|
+
return finish(await runNativeDot(source, 'dot', 'svg'));
|
|
90
107
|
if (tier === 'wasm') {
|
|
91
108
|
const gv = await loadWasmGraphviz();
|
|
92
109
|
if (gv)
|
|
93
|
-
return gv.layout(source, 'svg', 'dot');
|
|
110
|
+
return finish(gv.layout(source, 'svg', 'dot'));
|
|
111
|
+
// Only reachable when the caller pinned `wasm`; detection would have
|
|
112
|
+
// returned 'none'. Say which tier was asked for, not just that it failed.
|
|
113
|
+
throw new Error('the bundled WASM Graphviz was requested but could not load — WebAssembly ' +
|
|
114
|
+
'is disabled in this runtime (for example `node --jitless`).');
|
|
94
115
|
}
|
|
95
116
|
throw new Error('Graphviz is unavailable because WebAssembly is disabled in this runtime ' +
|
|
96
117
|
'(for example `node --jitless`). Install the `dot` binary, or use ' +
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icons for rendered diagrams.
|
|
3
|
+
*
|
|
4
|
+
* Two constraints shape this module, and they rule out the obvious answers:
|
|
5
|
+
*
|
|
6
|
+
* 1. **No native dependencies and no downloads.** Vendor icon sets (the AWS
|
|
7
|
+
* Architecture Icons, Azure's, GCP's) are large binary asset packs with
|
|
8
|
+
* their own trademark terms, so they are not something this tool can
|
|
9
|
+
* quietly vendor. These icons are therefore drawn here, by category —
|
|
10
|
+
* "database", "queue", "load balancer" — not by vendor. A category set
|
|
11
|
+
* also survives contact with a multi-cloud estate: an on-prem Postgres,
|
|
12
|
+
* an RDS instance and a Cloud SQL instance all want the same glyph.
|
|
13
|
+
* 2. **Engine parity.** The picture must be identical whether Graphviz ran
|
|
14
|
+
* as a native binary or as the bundled WASM build. Graphviz's own
|
|
15
|
+
* `image=` attribute is not viable for that: the native binary resolves
|
|
16
|
+
* it as a filesystem path, so a `data:` URI works in one tier and fails
|
|
17
|
+
* in the other.
|
|
18
|
+
*
|
|
19
|
+
* So the icon is not handed to Graphviz at all. `renderDot` reserves a
|
|
20
|
+
* fixed-size cell in the node's HTML-like label containing a 1pt marker token,
|
|
21
|
+
* Graphviz lays out the graph knowing exactly how much room the icon needs,
|
|
22
|
+
* and `injectIcons` swaps each marker for inline SVG afterwards. Both tiers
|
|
23
|
+
* emit the same SVG structure, so both get the same picture, and the result
|
|
24
|
+
* stays a self-contained SVG with no external references and no base64 bloat.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The visual family an entity belongs to. Icons carry the specific meaning;
|
|
28
|
+
* families carry the colour, so a diagram reads as a handful of zones rather
|
|
29
|
+
* than thirty unrelated hues.
|
|
30
|
+
*/
|
|
31
|
+
export type Family = 'edge' | 'compute' | 'data' | 'messaging' | 'security' | 'observability' | 'other';
|
|
32
|
+
export interface FamilyStyle {
|
|
33
|
+
/** Border and icon colour. */
|
|
34
|
+
accent: string;
|
|
35
|
+
/** Node fill — a wash of the accent, light enough for 9pt text on top. */
|
|
36
|
+
tint: string;
|
|
37
|
+
}
|
|
38
|
+
export declare const PALETTE: Record<Family, FamilyStyle>;
|
|
39
|
+
export type IconKey = 'dns' | 'cdn' | 'api' | 'loadbalancer' | 'network' | 'firewall' | 'certificate' | 'identity' | 'secret' | 'compute' | 'function' | 'container' | 'cluster' | 'service' | 'database' | 'storage' | 'cache' | 'queue' | 'topic' | 'events' | 'email' | 'monitoring' | 'user' | 'generic';
|
|
40
|
+
interface Icon {
|
|
41
|
+
family: Family;
|
|
42
|
+
/** SVG shapes on a 24x24 canvas. Stroke and fill are set by the wrapper. */
|
|
43
|
+
body: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Drawn on a 24x24 grid, stroked rather than filled so a single accent colour
|
|
47
|
+
* carries the whole glyph and one wrapper `<g>` can restyle it.
|
|
48
|
+
*/
|
|
49
|
+
export declare const ICONS: Record<IconKey, Icon>;
|
|
50
|
+
export declare function iconFor(kind: string): IconKey;
|
|
51
|
+
export declare function familyFor(kind: string): Family;
|
|
52
|
+
export declare function styleFor(kind: string): FamilyStyle;
|
|
53
|
+
/**
|
|
54
|
+
* Namespaced so it cannot collide with anything a provider might legitimately
|
|
55
|
+
* put in an entity name. `renderDot` additionally strips the prefix from
|
|
56
|
+
* labels, so a hostile model cannot inject its own icon.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately letters, digits, `_` and `@` only: Graphviz XML-escapes SVG
|
|
59
|
+
* text, so a hyphen would come back out as `-` and the marker would no
|
|
60
|
+
* longer match itself.
|
|
61
|
+
*/
|
|
62
|
+
export declare const MARKER_PREFIX = "@@dwicon_";
|
|
63
|
+
export declare function marker(key: IconKey): string;
|
|
64
|
+
/** Rendered size of the icon, in points, matching the cell `renderDot` reserves. */
|
|
65
|
+
export declare const ICON_SIZE = 24;
|
|
66
|
+
export declare function iconSvg(key: IconKey, x: number, y: number, color: string, size?: number): string;
|
|
67
|
+
/**
|
|
68
|
+
* Replaces every icon marker in a Graphviz-produced SVG with inline vector
|
|
69
|
+
* artwork. Anything it does not recognise is left exactly as it was, so a
|
|
70
|
+
* marker-free SVG (Mermaid, or DOT rendered by someone else) passes through
|
|
71
|
+
* untouched.
|
|
72
|
+
*/
|
|
73
|
+
export declare function injectIcons(svg: string, colorFor?: (nodeId: string) => string): string;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icons for rendered diagrams.
|
|
3
|
+
*
|
|
4
|
+
* Two constraints shape this module, and they rule out the obvious answers:
|
|
5
|
+
*
|
|
6
|
+
* 1. **No native dependencies and no downloads.** Vendor icon sets (the AWS
|
|
7
|
+
* Architecture Icons, Azure's, GCP's) are large binary asset packs with
|
|
8
|
+
* their own trademark terms, so they are not something this tool can
|
|
9
|
+
* quietly vendor. These icons are therefore drawn here, by category —
|
|
10
|
+
* "database", "queue", "load balancer" — not by vendor. A category set
|
|
11
|
+
* also survives contact with a multi-cloud estate: an on-prem Postgres,
|
|
12
|
+
* an RDS instance and a Cloud SQL instance all want the same glyph.
|
|
13
|
+
* 2. **Engine parity.** The picture must be identical whether Graphviz ran
|
|
14
|
+
* as a native binary or as the bundled WASM build. Graphviz's own
|
|
15
|
+
* `image=` attribute is not viable for that: the native binary resolves
|
|
16
|
+
* it as a filesystem path, so a `data:` URI works in one tier and fails
|
|
17
|
+
* in the other.
|
|
18
|
+
*
|
|
19
|
+
* So the icon is not handed to Graphviz at all. `renderDot` reserves a
|
|
20
|
+
* fixed-size cell in the node's HTML-like label containing a 1pt marker token,
|
|
21
|
+
* Graphviz lays out the graph knowing exactly how much room the icon needs,
|
|
22
|
+
* and `injectIcons` swaps each marker for inline SVG afterwards. Both tiers
|
|
23
|
+
* emit the same SVG structure, so both get the same picture, and the result
|
|
24
|
+
* stays a self-contained SVG with no external references and no base64 bloat.
|
|
25
|
+
*/
|
|
26
|
+
export const PALETTE = {
|
|
27
|
+
edge: { accent: '#0284c7', tint: '#f0f9ff' },
|
|
28
|
+
compute: { accent: '#d97706', tint: '#fffbeb' },
|
|
29
|
+
data: { accent: '#059669', tint: '#ecfdf5' },
|
|
30
|
+
messaging: { accent: '#7c3aed', tint: '#f5f3ff' },
|
|
31
|
+
security: { accent: '#e11d48', tint: '#fff1f2' },
|
|
32
|
+
observability: { accent: '#475569', tint: '#f8fafc' },
|
|
33
|
+
other: { accent: '#64748b', tint: '#f8fafc' },
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Drawn on a 24x24 grid, stroked rather than filled so a single accent colour
|
|
37
|
+
* carries the whole glyph and one wrapper `<g>` can restyle it.
|
|
38
|
+
*/
|
|
39
|
+
export const ICONS = {
|
|
40
|
+
dns: {
|
|
41
|
+
family: 'edge',
|
|
42
|
+
body: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/>' +
|
|
43
|
+
'<path d="M3.2 12h17.6M5 7h14M5 17h14"/>',
|
|
44
|
+
},
|
|
45
|
+
cdn: {
|
|
46
|
+
family: 'edge',
|
|
47
|
+
body: '<circle cx="12" cy="12" r="3.6"/>' +
|
|
48
|
+
'<path d="M12 8.4V4M12 15.6V20M8.9 10.2 5.1 8M15.1 13.8l3.8 2.2M8.9 13.8 5.1 16M15.1 10.2 18.9 8"/>' +
|
|
49
|
+
'<circle cx="12" cy="3" r="1.6"/><circle cx="12" cy="21" r="1.6"/>' +
|
|
50
|
+
'<circle cx="3.9" cy="7.3" r="1.6"/><circle cx="20.1" cy="16.7" r="1.6"/>' +
|
|
51
|
+
'<circle cx="3.9" cy="16.7" r="1.6"/><circle cx="20.1" cy="7.3" r="1.6"/>',
|
|
52
|
+
},
|
|
53
|
+
api: {
|
|
54
|
+
family: 'edge',
|
|
55
|
+
body: '<path d="M8.5 5.5 3.5 12l5 6.5M15.5 5.5 20.5 12l-5 6.5M13.6 4.2 10.4 19.8"/>',
|
|
56
|
+
},
|
|
57
|
+
loadbalancer: {
|
|
58
|
+
family: 'edge',
|
|
59
|
+
body: '<rect x="9" y="2.5" width="6" height="5" rx="1.5"/>' +
|
|
60
|
+
'<path d="M12 7.5v3M4 13.5v-3h16v3M12 10.5v3"/>' +
|
|
61
|
+
'<rect x="1.5" y="13.5" width="5" height="5" rx="1.5"/>' +
|
|
62
|
+
'<rect x="9.5" y="13.5" width="5" height="5" rx="1.5"/>' +
|
|
63
|
+
'<rect x="17.5" y="13.5" width="5" height="5" rx="1.5"/>',
|
|
64
|
+
},
|
|
65
|
+
network: {
|
|
66
|
+
family: 'edge',
|
|
67
|
+
body: '<circle cx="12" cy="4.6" r="2.6"/><circle cx="4.8" cy="18" r="2.6"/><circle cx="19.2" cy="18" r="2.6"/>' +
|
|
68
|
+
'<path d="M10.7 6.9 6.1 15.7M13.3 6.9l4.6 8.8M7.4 18h9.2"/>',
|
|
69
|
+
},
|
|
70
|
+
firewall: {
|
|
71
|
+
family: 'security',
|
|
72
|
+
body: '<path d="M12 2.5 20 5.4v6.2c0 4.9-3.4 8-8 9.9-4.6-1.9-8-5-8-9.9V5.4Z"/><path d="M8.4 12.1 11 14.7l4.6-5"/>',
|
|
73
|
+
},
|
|
74
|
+
certificate: {
|
|
75
|
+
family: 'security',
|
|
76
|
+
body: '<circle cx="12" cy="9" r="6.1"/><path d="M8.4 14.2v7.3L12 19.4l3.6 2.1v-7.3"/><path d="M9.7 9l1.7 1.8 3-3.4"/>',
|
|
77
|
+
},
|
|
78
|
+
identity: {
|
|
79
|
+
family: 'security',
|
|
80
|
+
body: '<rect x="2.8" y="3.2" width="18.4" height="17.6" rx="3"/>' +
|
|
81
|
+
'<circle cx="12" cy="10.2" r="2.9"/><path d="M7.2 17.9a4.9 4.9 0 0 1 9.6 0"/>',
|
|
82
|
+
},
|
|
83
|
+
secret: {
|
|
84
|
+
family: 'security',
|
|
85
|
+
body: '<circle cx="8.4" cy="8.4" r="4.6"/><path d="M11.7 11.7 20.4 20.4M15.6 19.2l2-2M18.1 16.7l2-2"/>',
|
|
86
|
+
},
|
|
87
|
+
compute: {
|
|
88
|
+
family: 'compute',
|
|
89
|
+
body: '<rect x="2.8" y="3.6" width="18.4" height="7" rx="1.8"/><rect x="2.8" y="13.4" width="18.4" height="7" rx="1.8"/>' +
|
|
90
|
+
'<circle cx="6.4" cy="7.1" r=".9" fill="currentColor" stroke="none"/>' +
|
|
91
|
+
'<circle cx="6.4" cy="16.9" r=".9" fill="currentColor" stroke="none"/>',
|
|
92
|
+
},
|
|
93
|
+
function: {
|
|
94
|
+
family: 'compute',
|
|
95
|
+
body: '<path d="M13.6 2.4 5.2 13.6h5.6L10.4 21.6l8.4-11.2h-5.6Z"/>',
|
|
96
|
+
},
|
|
97
|
+
container: {
|
|
98
|
+
family: 'compute',
|
|
99
|
+
body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M3.4 7 12 11.4 20.6 7M12 11.4v10"/>',
|
|
100
|
+
},
|
|
101
|
+
cluster: {
|
|
102
|
+
family: 'compute',
|
|
103
|
+
body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><circle cx="12" cy="12" r="3.2"/>' +
|
|
104
|
+
'<path d="M12 8.8V4.6M14.8 13.8l3.6 2.1M9.2 13.8l-3.6 2.1"/>',
|
|
105
|
+
},
|
|
106
|
+
service: {
|
|
107
|
+
family: 'compute',
|
|
108
|
+
body: '<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M8.4 12h7.2M12 8.4v7.2"/>',
|
|
109
|
+
},
|
|
110
|
+
database: {
|
|
111
|
+
family: 'data',
|
|
112
|
+
body: '<ellipse cx="12" cy="6" rx="8" ry="3.2"/>' +
|
|
113
|
+
'<path d="M4 6v12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2V6"/>' +
|
|
114
|
+
'<path d="M4 12c0 1.8 3.6 3.2 8 3.2s8-1.4 8-3.2"/>',
|
|
115
|
+
},
|
|
116
|
+
storage: {
|
|
117
|
+
family: 'data',
|
|
118
|
+
body: '<ellipse cx="12" cy="6.2" rx="8" ry="2.6"/><path d="M4 6.2 5.9 19a6.2 6.2 0 0 0 12.2 0L20 6.2"/>',
|
|
119
|
+
},
|
|
120
|
+
cache: {
|
|
121
|
+
family: 'data',
|
|
122
|
+
body: '<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9.6" y="9.6" width="4.8" height="4.8" rx="1"/>' +
|
|
123
|
+
'<path d="M9.2 6V2.8M14.8 6V2.8M9.2 18v3.2M14.8 18v3.2M6 9.2H2.8M6 14.8H2.8M18 9.2h3.2M18 14.8h3.2"/>',
|
|
124
|
+
},
|
|
125
|
+
queue: {
|
|
126
|
+
family: 'messaging',
|
|
127
|
+
body: '<rect x="2.4" y="6" width="19.2" height="12" rx="2"/><path d="M8.8 6v12M15.2 6v12"/>',
|
|
128
|
+
},
|
|
129
|
+
topic: {
|
|
130
|
+
family: 'messaging',
|
|
131
|
+
body: '<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/>' +
|
|
132
|
+
'<path d="M8 8.2a5.4 5.4 0 0 0 0 7.6M16 8.2a5.4 5.4 0 0 1 0 7.6"/>' +
|
|
133
|
+
'<path d="M5.2 5.2a9.6 9.6 0 0 0 0 13.6M18.8 5.2a9.6 9.6 0 0 1 0 13.6"/>',
|
|
134
|
+
},
|
|
135
|
+
events: {
|
|
136
|
+
family: 'messaging',
|
|
137
|
+
body: '<path d="M12 2.4 21.6 12 12 21.6 2.4 12Z"/><path d="M12.9 7.2 9.4 12.6h2.5l-.8 4.2 3.5-5.4h-2.5Z"/>',
|
|
138
|
+
},
|
|
139
|
+
email: {
|
|
140
|
+
family: 'messaging',
|
|
141
|
+
body: '<rect x="2.4" y="5" width="19.2" height="14" rx="2"/><path d="M3.2 7 12 13.4 20.8 7"/>',
|
|
142
|
+
},
|
|
143
|
+
monitoring: {
|
|
144
|
+
family: 'observability',
|
|
145
|
+
body: '<path d="M3.4 3.4v17.2h17.2"/><path d="M6.6 16.6 11 11.2l3.4 2.8 5.4-7"/>',
|
|
146
|
+
},
|
|
147
|
+
user: {
|
|
148
|
+
family: 'other',
|
|
149
|
+
body: '<circle cx="12" cy="7.6" r="4"/><path d="M4.4 20.6a7.6 7.6 0 0 1 15.2 0"/>',
|
|
150
|
+
},
|
|
151
|
+
generic: {
|
|
152
|
+
family: 'other',
|
|
153
|
+
body: '<rect x="3.4" y="3.4" width="17.2" height="17.2" rx="3.4"/><path d="M8.6 12h6.8"/>',
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* `kind` is an open string by design, so this is pattern matching over
|
|
158
|
+
* vocabularies rather than a lookup table — Terraform types, Kubernetes kinds,
|
|
159
|
+
* Dynatrace entity types and anything a future provider invents.
|
|
160
|
+
*
|
|
161
|
+
* **Order is the whole trick.** More specific patterns must come first, and
|
|
162
|
+
* several pairs actively collide: `aws_elasticache_cluster` is a cache, not a
|
|
163
|
+
* Kubernetes cluster; `aws_db_instance` is a database, not an EC2 instance;
|
|
164
|
+
* `aws_db_subnet_group` is data, not network. Those cases are pinned by tests.
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* Whole-token match. `\b` is no use here: `_` is a word character, so `\bses\b`
|
|
168
|
+
* never matches `aws_ses_receipt_rule`, and `\bkey\b` never matches
|
|
169
|
+
* `aws_kms_key`. Separators in the wild are `_`, `-`, `.` and case changes, so
|
|
170
|
+
* the boundary is "anything that isn't alphanumeric".
|
|
171
|
+
*/
|
|
172
|
+
function tok(alternatives) {
|
|
173
|
+
return `(?:^|[^a-z0-9])(?:${alternatives})(?:[^a-z0-9]|$)`;
|
|
174
|
+
}
|
|
175
|
+
function rule(source, key) {
|
|
176
|
+
return [new RegExp(source, 'i'), key];
|
|
177
|
+
}
|
|
178
|
+
const KIND_ICONS = [
|
|
179
|
+
rule(`route53|hosted_?zone|dns|record_?set|${tok('record|zone|zones')}`, 'dns'),
|
|
180
|
+
rule(`cloudfront|front_?door|fastly|akamai|${tok('cdn')}`, 'cdn'),
|
|
181
|
+
rule(`certificate|${tok('acm|cert|certs|tls|ssl')}`, 'certificate'),
|
|
182
|
+
rule(`waf|firewall|security_?group|network_?acl|${tok('nacl|nsg|acl')}`, 'firewall'),
|
|
183
|
+
rule(`load_?balanc|target_?group|ingress|${tok('lb|alb|nlb|elb')}`, 'loadbalancer'),
|
|
184
|
+
rule(`api_?gateway|apigw|appsync|graphql|endpoint|${tok('api')}`, 'api'),
|
|
185
|
+
rule(`elasticache|memcache|redis|${tok('cache')}`, 'cache'),
|
|
186
|
+
rule(`rds|dynamodb|aurora|spanner|cosmos|mongo|postgres|mysql|bigtable|firestore|database|${tok('db|sql')}`, 'database'),
|
|
187
|
+
rule(`bucket|blob|storage|volume|disk|file_?system|glacier|${tok('s3|efs|fs')}`, 'storage'),
|
|
188
|
+
rule(`vpc|subnet|vnet|nat_?gateway|route_?table|peering|transit_?gateway|network_?interface|${tok('nat|cidr')}`, 'network'),
|
|
189
|
+
rule('event_?bridge|event_?bus|event_?rule|event_?source|scheduler', 'events'),
|
|
190
|
+
rule(`sqs|queue|kinesis|kafka|rabbit|stream|${tok('msk')}`, 'queue'),
|
|
191
|
+
rule('sns|topic|pubsub|subscription|notification', 'topic'),
|
|
192
|
+
rule(`smtp|email|sendgrid|${tok('ses|mail')}`, 'email'),
|
|
193
|
+
rule(`task_?definition|container|docker|image|${tok('ecr|pod|pods')}`, 'container'),
|
|
194
|
+
rule(`cluster|node_?group|node_?pool|${tok('eks|gke|aks')}`, 'cluster'),
|
|
195
|
+
rule('lambda|function|serverless', 'function'),
|
|
196
|
+
rule(`virtual_?machine|autoscal|compute|${tok('ec2|vm|asg|instance|instances|server|servers|host|hosts')}`, 'compute'),
|
|
197
|
+
rule('service|deployment|app_?service|workload|daemonset|statefulset|replicaset', 'service'),
|
|
198
|
+
rule(`iam|policy|principal|service_?account|user_?pool|cognito|${tok('role|roles|auth|rbac')}`, 'identity'),
|
|
199
|
+
rule(`secret|key_?vault|parameter_?store|ssm_parameter|vault|${tok('kms|key|keys')}`, 'secret'),
|
|
200
|
+
rule(`cloudwatch|metric|alarm|monitor|prometheus|grafana|dashboard|log_?group|telemetry|${tok('log|logs|trace|traces')}`, 'monitoring'),
|
|
201
|
+
rule(`browser|customer|${tok('user|users|client|clients|actor|external')}`, 'user'),
|
|
202
|
+
];
|
|
203
|
+
export function iconFor(kind) {
|
|
204
|
+
for (const [pattern, key] of KIND_ICONS)
|
|
205
|
+
if (pattern.test(kind))
|
|
206
|
+
return key;
|
|
207
|
+
return 'generic';
|
|
208
|
+
}
|
|
209
|
+
export function familyFor(kind) {
|
|
210
|
+
return ICONS[iconFor(kind)].family;
|
|
211
|
+
}
|
|
212
|
+
export function styleFor(kind) {
|
|
213
|
+
return PALETTE[familyFor(kind)];
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Namespaced so it cannot collide with anything a provider might legitimately
|
|
217
|
+
* put in an entity name. `renderDot` additionally strips the prefix from
|
|
218
|
+
* labels, so a hostile model cannot inject its own icon.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately letters, digits, `_` and `@` only: Graphviz XML-escapes SVG
|
|
221
|
+
* text, so a hyphen would come back out as `-` and the marker would no
|
|
222
|
+
* longer match itself.
|
|
223
|
+
*/
|
|
224
|
+
export const MARKER_PREFIX = '@@dwicon_';
|
|
225
|
+
export function marker(key) {
|
|
226
|
+
return `${MARKER_PREFIX}${key}@@`;
|
|
227
|
+
}
|
|
228
|
+
/** Rendered size of the icon, in points, matching the cell `renderDot` reserves. */
|
|
229
|
+
export const ICON_SIZE = 24;
|
|
230
|
+
export function iconSvg(key, x, y, color, size = ICON_SIZE) {
|
|
231
|
+
const icon = ICONS[key] ?? ICONS.generic;
|
|
232
|
+
const scale = size / 24;
|
|
233
|
+
const left = round(x - size / 2);
|
|
234
|
+
const top = round(y - size / 2);
|
|
235
|
+
return (`<g class="driftwood-icon" transform="translate(${left} ${top}) scale(${round(scale)})" ` +
|
|
236
|
+
`fill="none" stroke="${color}" color="${color}" stroke-width="1.7" ` +
|
|
237
|
+
`stroke-linecap="round" stroke-linejoin="round">${icon.body}</g>`);
|
|
238
|
+
}
|
|
239
|
+
function round(n) {
|
|
240
|
+
return Math.round(n * 100) / 100;
|
|
241
|
+
}
|
|
242
|
+
const NODE_RE = /<g id="node\d+" class="node">[\s\S]*?<\/g>/g;
|
|
243
|
+
const MARKER_TEXT_RE = new RegExp(`<text\\b[^>]*\\bx="(-?[\\d.]+)"[^>]*\\by="(-?[\\d.]+)"[^>]*>${escapeRe(MARKER_PREFIX)}([a-z]+)@@</text>`);
|
|
244
|
+
function escapeRe(s) {
|
|
245
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Horizontal centre of a node's shape.
|
|
249
|
+
*
|
|
250
|
+
* The marker text's own `x` is the *start* of the run, which depends on the
|
|
251
|
+
* marker's width in the current font — unusable. The node's outline is not:
|
|
252
|
+
* every label this renderer emits is a single-column table, so the icon cell's
|
|
253
|
+
* centre is the shape's centre.
|
|
254
|
+
*/
|
|
255
|
+
function centreX(nodeSvg) {
|
|
256
|
+
const ellipse = nodeSvg.match(/<ellipse[^>]*\bcx="(-?[\d.]+)"/);
|
|
257
|
+
if (ellipse?.[1])
|
|
258
|
+
return Number(ellipse[1]);
|
|
259
|
+
const geometry = nodeSvg.match(/<(?:polygon|path)[^>]*\b(?:points|d)="([^"]+)"/);
|
|
260
|
+
if (!geometry?.[1])
|
|
261
|
+
return undefined;
|
|
262
|
+
const numbers = geometry[1]
|
|
263
|
+
.replace(/[A-Za-z]/g, ' ')
|
|
264
|
+
.split(/[\s,]+/)
|
|
265
|
+
.filter((t) => t !== '')
|
|
266
|
+
.map(Number);
|
|
267
|
+
const xs = [];
|
|
268
|
+
for (let i = 0; i < numbers.length; i += 2) {
|
|
269
|
+
const x = numbers[i];
|
|
270
|
+
if (x !== undefined && Number.isFinite(x))
|
|
271
|
+
xs.push(x);
|
|
272
|
+
}
|
|
273
|
+
if (xs.length === 0)
|
|
274
|
+
return undefined;
|
|
275
|
+
return (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Replaces every icon marker in a Graphviz-produced SVG with inline vector
|
|
279
|
+
* artwork. Anything it does not recognise is left exactly as it was, so a
|
|
280
|
+
* marker-free SVG (Mermaid, or DOT rendered by someone else) passes through
|
|
281
|
+
* untouched.
|
|
282
|
+
*/
|
|
283
|
+
export function injectIcons(svg, colorFor) {
|
|
284
|
+
return svg.replace(NODE_RE, (node) => {
|
|
285
|
+
const found = MARKER_TEXT_RE.exec(node);
|
|
286
|
+
if (!found)
|
|
287
|
+
return node;
|
|
288
|
+
const [element, , yRaw, key] = found;
|
|
289
|
+
const y = Number(yRaw);
|
|
290
|
+
const x = centreX(node);
|
|
291
|
+
if (x === undefined || !Number.isFinite(y))
|
|
292
|
+
return node.replace(element, '');
|
|
293
|
+
const id = node.match(/<title>([\s\S]*?)<\/title>/)?.[1];
|
|
294
|
+
const color = colorFor && id ? colorFor(unescapeXml(id)) : PALETTE.other.accent;
|
|
295
|
+
return node.replace(element, iconSvg((key ?? 'generic'), x, y, color));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function unescapeXml(s) {
|
|
299
|
+
return s
|
|
300
|
+
.replace(/-/g, '-')
|
|
301
|
+
.replace(/</g, '<')
|
|
302
|
+
.replace(/>/g, '>')
|
|
303
|
+
.replace(/"/g, '"')
|
|
304
|
+
.replace(/'/g, "'")
|
|
305
|
+
.replace(/&/g, '&');
|
|
306
|
+
}
|
package/dist/render/mermaid.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PALETTE, familyFor, iconFor } from './icons.js';
|
|
1
2
|
import { selectEntities } from './select.js';
|
|
2
3
|
import { defineRenderer } from './types.js';
|
|
3
4
|
/**
|
|
@@ -13,35 +14,35 @@ function escapeLabel(text) {
|
|
|
13
14
|
// Mermaid renders <br/> inside a quoted label; a raw newline breaks parsing.
|
|
14
15
|
return text.replace(/"/g, '"').replace(/\n/g, '<br/>');
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
}
|
|
17
|
+
/**
|
|
18
|
+
* Mermaid has no icon primitive, so the same categorisation that picks an icon
|
|
19
|
+
* for the Graphviz renderer picks a shape and a colour here. Sharing it is the
|
|
20
|
+
* point: a queue must not be a cylinder in one engine and a parallelogram in
|
|
21
|
+
* the other, or the two pictures stop describing the same system.
|
|
22
|
+
*/
|
|
23
|
+
const SHAPES = {
|
|
24
|
+
database: (id, l) => `${id}[("${l}")]`,
|
|
25
|
+
storage: (id, l) => `${id}[("${l}")]`,
|
|
26
|
+
cache: (id, l) => `${id}[("${l}")]`,
|
|
27
|
+
queue: (id, l) => `${id}[/"${l}"/]`,
|
|
28
|
+
topic: (id, l) => `${id}[/"${l}"/]`,
|
|
29
|
+
events: (id, l) => `${id}[/"${l}"/]`,
|
|
30
|
+
email: (id, l) => `${id}[/"${l}"/]`,
|
|
31
|
+
dns: (id, l) => `${id}("${l}")`,
|
|
32
|
+
cdn: (id, l) => `${id}("${l}")`,
|
|
33
|
+
network: (id, l) => `${id}("${l}")`,
|
|
34
|
+
loadbalancer: (id, l) => `${id}("${l}")`,
|
|
35
|
+
api: (id, l) => `${id}("${l}")`,
|
|
36
|
+
identity: (id, l) => `${id}{{"${l}"}}`,
|
|
37
|
+
secret: (id, l) => `${id}{{"${l}"}}`,
|
|
38
|
+
certificate: (id, l) => `${id}{{"${l}"}}`,
|
|
39
|
+
firewall: (id, l) => `${id}{{"${l}"}}`,
|
|
40
|
+
};
|
|
30
41
|
function renderNode(e) {
|
|
31
42
|
const label = escapeLabel(`${e.name ?? e.id}\n${e.kind}`);
|
|
32
43
|
const id = nodeId(e.id);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return `${id}[("${label}")]`;
|
|
36
|
-
case 'messaging':
|
|
37
|
-
return `${id}[/"${label}"/]`;
|
|
38
|
-
case 'network':
|
|
39
|
-
return `${id}("${label}")`;
|
|
40
|
-
case 'identity':
|
|
41
|
-
return `${id}{{"${label}"}}`;
|
|
42
|
-
default:
|
|
43
|
-
return `${id}["${label}"]`;
|
|
44
|
-
}
|
|
44
|
+
const shape = SHAPES[iconFor(e.kind)];
|
|
45
|
+
return shape ? shape(id, label) : `${id}["${label}"]`;
|
|
45
46
|
}
|
|
46
47
|
export function renderMermaid(model, opts = {}) {
|
|
47
48
|
const view = opts.view ? model.views.find((v) => v.id === opts.view) : undefined;
|
|
@@ -79,6 +80,18 @@ export function renderMermaid(model, opts = {}) {
|
|
|
79
80
|
const label = edge.label ? `|"${escapeLabel(edge.label)}"|` : '';
|
|
80
81
|
lines.push(` ${nodeId(edge.from)} -->${label} ${nodeId(edge.to)}`);
|
|
81
82
|
}
|
|
83
|
+
// Family colours, matching the Graphviz renderer's palette. Emitted before
|
|
84
|
+
// the health overlay so that health, being the more urgent fact, wins.
|
|
85
|
+
const families = new Set();
|
|
86
|
+
for (const e of entities) {
|
|
87
|
+
const family = familyFor(e.kind);
|
|
88
|
+
families.add(family);
|
|
89
|
+
lines.push(` class ${nodeId(e.id)} f_${family};`);
|
|
90
|
+
}
|
|
91
|
+
for (const family of [...families].sort()) {
|
|
92
|
+
const { accent, tint } = PALETTE[family];
|
|
93
|
+
lines.push(` classDef f_${family} fill:${tint},stroke:${accent},color:#0f172a;`);
|
|
94
|
+
}
|
|
82
95
|
if (opts.health) {
|
|
83
96
|
lines.push('');
|
|
84
97
|
for (const [id, status] of Object.entries(opts.health)) {
|
package/dist/render/types.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export interface RenderContext {
|
|
|
4
4
|
direction?: 'LR' | 'TD';
|
|
5
5
|
/** Runtime health, applied at render time. Never committed to the model. */
|
|
6
6
|
health?: Record<string, 'healthy' | 'degraded' | 'down'>;
|
|
7
|
+
/**
|
|
8
|
+
* Draw category icons on nodes. Only renderers that produce final artwork
|
|
9
|
+
* honour this — Mermaid has no icon primitive, and raw DOT source has no
|
|
10
|
+
* post-processing step to turn a marker into a picture.
|
|
11
|
+
*/
|
|
12
|
+
icons?: boolean;
|
|
7
13
|
}
|
|
8
14
|
export interface Availability {
|
|
9
15
|
available: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brignano/driftwood",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Architecture as code, reconciled with live infrastructure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"dev": "tsx src/cli.ts",
|
|
44
|
+
"docs": "tsx scripts/render-docs.ts",
|
|
44
45
|
"test": "vitest run",
|
|
45
46
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
47
|
},
|
|
@@ -54,6 +55,6 @@
|
|
|
54
55
|
"@types/node": "^22.7.4",
|
|
55
56
|
"tsx": "^4.19.1",
|
|
56
57
|
"typescript": "^5.6.2",
|
|
57
|
-
"vitest": "^2.
|
|
58
|
+
"vitest": "^3.2.6"
|
|
58
59
|
}
|
|
59
60
|
}
|