@clarity-tools/cli 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 +158 -0
- package/dist/index.js +2610 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2610 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command2 } from "commander";
|
|
5
|
+
|
|
6
|
+
// ../core/src/graph/schema.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
var PortMappingSchema = z.object({
|
|
9
|
+
internal: z.number(),
|
|
10
|
+
external: z.number().optional()
|
|
11
|
+
});
|
|
12
|
+
var VolumeMountSchema = z.object({
|
|
13
|
+
source: z.string(),
|
|
14
|
+
target: z.string(),
|
|
15
|
+
type: z.enum(["volume", "bind", "tmpfs"]).optional()
|
|
16
|
+
});
|
|
17
|
+
var SourceInfoSchema = z.object({
|
|
18
|
+
file: z.string(),
|
|
19
|
+
format: z.enum(["docker-compose", "helm", "terraform", "ansible"]),
|
|
20
|
+
line: z.number().optional()
|
|
21
|
+
});
|
|
22
|
+
var ServiceNodeSchema = z.object({
|
|
23
|
+
id: z.string().min(1),
|
|
24
|
+
name: z.string(),
|
|
25
|
+
type: z.enum([
|
|
26
|
+
"container",
|
|
27
|
+
"database",
|
|
28
|
+
"cache",
|
|
29
|
+
"queue",
|
|
30
|
+
"storage",
|
|
31
|
+
"proxy",
|
|
32
|
+
"ui"
|
|
33
|
+
]),
|
|
34
|
+
source: SourceInfoSchema,
|
|
35
|
+
image: z.string().optional(),
|
|
36
|
+
ports: z.array(PortMappingSchema).optional(),
|
|
37
|
+
volumes: z.array(VolumeMountSchema).optional(),
|
|
38
|
+
// Environment values can be strings, numbers, booleans, or null (pass-through from host)
|
|
39
|
+
environment: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
|
40
|
+
replicas: z.number().optional(),
|
|
41
|
+
resourceRequests: z.object({
|
|
42
|
+
cpu: z.string().optional(),
|
|
43
|
+
memory: z.string().optional()
|
|
44
|
+
}).optional(),
|
|
45
|
+
storageSize: z.string().optional(),
|
|
46
|
+
external: z.boolean().optional(),
|
|
47
|
+
description: z.string().optional(),
|
|
48
|
+
group: z.string().optional(),
|
|
49
|
+
queueRole: z.enum(["producer", "consumer", "both"]).optional()
|
|
50
|
+
});
|
|
51
|
+
var DependencyEdgeSchema = z.object({
|
|
52
|
+
from: z.string(),
|
|
53
|
+
to: z.string(),
|
|
54
|
+
type: z.enum([
|
|
55
|
+
"depends_on",
|
|
56
|
+
"network",
|
|
57
|
+
"volume",
|
|
58
|
+
"link",
|
|
59
|
+
"inferred",
|
|
60
|
+
"subchart"
|
|
61
|
+
]),
|
|
62
|
+
port: z.number().optional(),
|
|
63
|
+
protocol: z.string().optional()
|
|
64
|
+
});
|
|
65
|
+
var GraphMetadataSchema = z.object({
|
|
66
|
+
project: z.string(),
|
|
67
|
+
parsedAt: z.string().datetime(),
|
|
68
|
+
sourceFiles: z.array(z.string()),
|
|
69
|
+
parserVersion: z.string()
|
|
70
|
+
});
|
|
71
|
+
var InfraGraphSchema = z.object({
|
|
72
|
+
nodes: z.array(ServiceNodeSchema),
|
|
73
|
+
edges: z.array(DependencyEdgeSchema),
|
|
74
|
+
metadata: GraphMetadataSchema
|
|
75
|
+
}).refine(
|
|
76
|
+
(graph) => {
|
|
77
|
+
const nodeIds = new Set(graph.nodes.map((n) => n.id));
|
|
78
|
+
return graph.edges.every((e) => nodeIds.has(e.from) && nodeIds.has(e.to));
|
|
79
|
+
},
|
|
80
|
+
{ message: "Edge references non-existent node" }
|
|
81
|
+
).refine(
|
|
82
|
+
(graph) => {
|
|
83
|
+
const ids = graph.nodes.map((n) => n.id);
|
|
84
|
+
return new Set(ids).size === ids.length;
|
|
85
|
+
},
|
|
86
|
+
{ message: "Duplicate node IDs detected" }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ../core/src/graph/builder.ts
|
|
90
|
+
var GraphBuilder = class {
|
|
91
|
+
nodes = /* @__PURE__ */ new Map();
|
|
92
|
+
edges = [];
|
|
93
|
+
sourceFiles = [];
|
|
94
|
+
project;
|
|
95
|
+
constructor(project) {
|
|
96
|
+
this.project = project;
|
|
97
|
+
}
|
|
98
|
+
addSourceFile(file) {
|
|
99
|
+
if (!this.sourceFiles.includes(file)) {
|
|
100
|
+
this.sourceFiles.push(file);
|
|
101
|
+
}
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
addNode(id, name, type, source, options) {
|
|
105
|
+
this.nodes.set(id, {
|
|
106
|
+
id,
|
|
107
|
+
name,
|
|
108
|
+
type,
|
|
109
|
+
source,
|
|
110
|
+
...options
|
|
111
|
+
});
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
addEdge(from, to, type, options) {
|
|
115
|
+
if (this.nodes.has(from) && this.nodes.has(to)) {
|
|
116
|
+
const existingEdge = this.edges.find(
|
|
117
|
+
(e) => e.from === from && e.to === to
|
|
118
|
+
);
|
|
119
|
+
if (type === "inferred" && existingEdge) {
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
const duplicateExists = this.edges.some(
|
|
123
|
+
(e) => e.from === from && e.to === to && e.type === type
|
|
124
|
+
);
|
|
125
|
+
if (duplicateExists) {
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
this.edges.push({
|
|
129
|
+
from,
|
|
130
|
+
to,
|
|
131
|
+
type,
|
|
132
|
+
...options
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
hasNode(id) {
|
|
138
|
+
return this.nodes.has(id);
|
|
139
|
+
}
|
|
140
|
+
getNode(id) {
|
|
141
|
+
return this.nodes.get(id);
|
|
142
|
+
}
|
|
143
|
+
build() {
|
|
144
|
+
return {
|
|
145
|
+
nodes: Array.from(this.nodes.values()),
|
|
146
|
+
edges: this.edges,
|
|
147
|
+
metadata: {
|
|
148
|
+
project: this.project,
|
|
149
|
+
parsedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
150
|
+
sourceFiles: this.sourceFiles,
|
|
151
|
+
parserVersion: "0.1.0"
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ../core/src/parsers/docker-compose.ts
|
|
158
|
+
import { parse as parseYaml } from "yaml";
|
|
159
|
+
|
|
160
|
+
// ../core/src/parsers/utils.ts
|
|
161
|
+
function inferServiceType(serviceName, image) {
|
|
162
|
+
const name = (image ?? serviceName).toLowerCase();
|
|
163
|
+
if (name.includes("postgres") || name.includes("mysql") || name.includes("mariadb") || name.includes("mongo") || name.includes("clickhouse") || name.includes("cassandra") || name.includes("cockroach") || name.includes("schema")) {
|
|
164
|
+
return "database";
|
|
165
|
+
}
|
|
166
|
+
if (name.includes("redis") || name.includes("memcache") || name.includes("keydb") || name.includes("elasticsearch") || name.includes("opensearch")) {
|
|
167
|
+
return "cache";
|
|
168
|
+
}
|
|
169
|
+
if (name.includes("kafka") || name.includes("rabbitmq") || name.includes("nats") || name.includes("pulsar") || name.includes("zookeeper")) {
|
|
170
|
+
return "queue";
|
|
171
|
+
}
|
|
172
|
+
if (name.includes("minio") || name.includes("seaweedfs") || name.includes("seaweed") || name.includes("objectstorage") || name.includes("s3") || name.includes("gcs") || name.includes("ceph")) {
|
|
173
|
+
return "storage";
|
|
174
|
+
}
|
|
175
|
+
if (name.includes("nginx") || name.includes("traefik") || name.includes("haproxy") || name.includes("envoy") || name.includes("caddy")) {
|
|
176
|
+
return "proxy";
|
|
177
|
+
}
|
|
178
|
+
if ((name.includes("web") || name.includes("frontend") || name.includes("ui") || name.includes("console") || name.includes("dashboard") || name.includes("portal")) && !name.includes("grafana") && !name.includes("kibana")) {
|
|
179
|
+
return "ui";
|
|
180
|
+
}
|
|
181
|
+
return "container";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ../core/src/parsers/docker-compose.ts
|
|
185
|
+
var yamlParseOptions = {
|
|
186
|
+
// Allow unlimited aliases for complex docker-compose files like Sentry
|
|
187
|
+
maxAliasCount: -1,
|
|
188
|
+
// Enable YAML merge key (<<) support for docker-compose anchor inheritance
|
|
189
|
+
merge: true
|
|
190
|
+
};
|
|
191
|
+
function parsePort(port) {
|
|
192
|
+
if (typeof port === "object") {
|
|
193
|
+
if (port.target) {
|
|
194
|
+
return {
|
|
195
|
+
internal: port.target,
|
|
196
|
+
external: port.published
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const str = String(port);
|
|
202
|
+
const parts = str.split(":").map((p) => p.split("/")[0]);
|
|
203
|
+
if (parts.length === 1) {
|
|
204
|
+
const num = Number.parseInt(parts[0] ?? "", 10);
|
|
205
|
+
return Number.isNaN(num) ? null : { internal: num };
|
|
206
|
+
}
|
|
207
|
+
if (parts.length === 2) {
|
|
208
|
+
const ext = Number.parseInt(parts[0] ?? "", 10);
|
|
209
|
+
const int = Number.parseInt(parts[1] ?? "", 10);
|
|
210
|
+
return Number.isNaN(ext) || Number.isNaN(int) ? null : { internal: int, external: ext };
|
|
211
|
+
}
|
|
212
|
+
if (parts.length === 3) {
|
|
213
|
+
const ext = Number.parseInt(parts[1] ?? "", 10);
|
|
214
|
+
const int = Number.parseInt(parts[2] ?? "", 10);
|
|
215
|
+
return Number.isNaN(ext) || Number.isNaN(int) ? null : { internal: int, external: ext };
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function parseVolume(vol) {
|
|
220
|
+
if (typeof vol === "object") {
|
|
221
|
+
if (vol.source && vol.target) {
|
|
222
|
+
return {
|
|
223
|
+
source: vol.source,
|
|
224
|
+
target: vol.target,
|
|
225
|
+
type: vol.type
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const str = String(vol);
|
|
231
|
+
const parts = str.split(":");
|
|
232
|
+
if (parts.length >= 2) {
|
|
233
|
+
const source = parts[0];
|
|
234
|
+
const target = parts[1];
|
|
235
|
+
if (source && target) {
|
|
236
|
+
const type = source.startsWith("/") || source.startsWith(".") ? "bind" : "volume";
|
|
237
|
+
return { source, target, type };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function parseEnvironment(env) {
|
|
243
|
+
if (!env) return void 0;
|
|
244
|
+
if (Array.isArray(env)) {
|
|
245
|
+
const record = {};
|
|
246
|
+
for (const item of env) {
|
|
247
|
+
const idx = item.indexOf("=");
|
|
248
|
+
if (idx > 0) {
|
|
249
|
+
const key = item.slice(0, idx);
|
|
250
|
+
const value = item.slice(idx + 1);
|
|
251
|
+
record[key] = value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return Object.keys(record).length > 0 ? record : void 0;
|
|
255
|
+
}
|
|
256
|
+
return Object.keys(env).length > 0 ? env : void 0;
|
|
257
|
+
}
|
|
258
|
+
function parseDockerCompose(content, filename, project) {
|
|
259
|
+
const compose = parseYaml(content, yamlParseOptions);
|
|
260
|
+
if (!compose?.services) {
|
|
261
|
+
return {
|
|
262
|
+
nodes: [],
|
|
263
|
+
edges: [],
|
|
264
|
+
metadata: {
|
|
265
|
+
project,
|
|
266
|
+
parsedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
267
|
+
sourceFiles: [filename],
|
|
268
|
+
parserVersion: "0.1.0"
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const builder = new GraphBuilder(project);
|
|
273
|
+
builder.addSourceFile(filename);
|
|
274
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
275
|
+
const image = service.image ?? (typeof service.build === "string" ? `build:${service.build}` : void 0);
|
|
276
|
+
const type = inferServiceType(serviceName, image);
|
|
277
|
+
const ports = service.ports?.map(parsePort).filter((p) => p !== null);
|
|
278
|
+
const volumes = service.volumes?.map(parseVolume).filter((v) => v !== null);
|
|
279
|
+
const environment = parseEnvironment(service.environment);
|
|
280
|
+
builder.addNode(
|
|
281
|
+
serviceName,
|
|
282
|
+
serviceName,
|
|
283
|
+
type,
|
|
284
|
+
{ file: filename, format: "docker-compose" },
|
|
285
|
+
{
|
|
286
|
+
image,
|
|
287
|
+
ports: ports?.length ? ports : void 0,
|
|
288
|
+
volumes: volumes?.length ? volumes : void 0,
|
|
289
|
+
environment,
|
|
290
|
+
replicas: service.deploy?.replicas
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
295
|
+
if (service.depends_on) {
|
|
296
|
+
const deps = Array.isArray(service.depends_on) ? service.depends_on : Object.keys(service.depends_on);
|
|
297
|
+
for (const dep of deps) {
|
|
298
|
+
builder.addEdge(serviceName, dep, "depends_on");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (service.links) {
|
|
302
|
+
for (const link of service.links) {
|
|
303
|
+
const target = link.split(":")[0];
|
|
304
|
+
if (target) {
|
|
305
|
+
builder.addEdge(serviceName, target, "link");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (service.environment) {
|
|
310
|
+
const envVars = Array.isArray(service.environment) ? service.environment : Object.entries(service.environment).map(([k, v]) => `${k}=${v}`);
|
|
311
|
+
for (const envVar of envVars) {
|
|
312
|
+
for (const otherService of Object.keys(compose.services)) {
|
|
313
|
+
if (otherService === serviceName) continue;
|
|
314
|
+
const envLower = envVar.toLowerCase();
|
|
315
|
+
const servicePattern = otherService.toLowerCase().replace(/-/g, "_");
|
|
316
|
+
if (envLower.includes(`${servicePattern}_host`) || envLower.includes(`${servicePattern}_url`) || envLower.includes(`${servicePattern}:`) || envVar.includes(`://${otherService}:`) || envVar.includes(`://${otherService}/`)) {
|
|
317
|
+
builder.addEdge(serviceName, otherService, "inferred");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return builder.build();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ../core/src/parsers/helm/index.ts
|
|
327
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
328
|
+
import { join as join2, relative } from "path";
|
|
329
|
+
|
|
330
|
+
// ../core/src/parsers/helm/chart.ts
|
|
331
|
+
import { parse as parseYaml2 } from "yaml";
|
|
332
|
+
var yamlParseOptions2 = {
|
|
333
|
+
maxAliasCount: -1,
|
|
334
|
+
merge: true
|
|
335
|
+
};
|
|
336
|
+
function isRecord(value) {
|
|
337
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
338
|
+
}
|
|
339
|
+
function parseDependencies(value) {
|
|
340
|
+
if (!Array.isArray(value)) return [];
|
|
341
|
+
const deps = [];
|
|
342
|
+
for (const item of value) {
|
|
343
|
+
if (!isRecord(item)) continue;
|
|
344
|
+
const name = typeof item.name === "string" ? item.name : void 0;
|
|
345
|
+
if (!name) continue;
|
|
346
|
+
const tags = Array.isArray(item.tags) && item.tags.every((t) => typeof t === "string") ? item.tags : void 0;
|
|
347
|
+
deps.push({
|
|
348
|
+
name,
|
|
349
|
+
version: typeof item.version === "string" ? item.version : void 0,
|
|
350
|
+
repository: typeof item.repository === "string" ? item.repository : void 0,
|
|
351
|
+
condition: typeof item.condition === "string" ? item.condition : void 0,
|
|
352
|
+
tags
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return deps;
|
|
356
|
+
}
|
|
357
|
+
function parseChartYaml(content) {
|
|
358
|
+
const chart = parseYaml2(content, yamlParseOptions2);
|
|
359
|
+
if (!isRecord(chart)) return null;
|
|
360
|
+
const name = typeof chart.name === "string" ? chart.name : void 0;
|
|
361
|
+
if (!name) return null;
|
|
362
|
+
return {
|
|
363
|
+
name,
|
|
364
|
+
version: typeof chart.version === "string" ? chart.version : void 0,
|
|
365
|
+
appVersion: typeof chart.appVersion === "string" ? chart.appVersion : void 0,
|
|
366
|
+
description: typeof chart.description === "string" ? chart.description : void 0,
|
|
367
|
+
dependencies: parseDependencies(chart.dependencies)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ../core/src/parsers/helm/values.ts
|
|
372
|
+
import { parse as parseYaml3 } from "yaml";
|
|
373
|
+
var yamlParseOptions3 = {
|
|
374
|
+
maxAliasCount: -1,
|
|
375
|
+
merge: true
|
|
376
|
+
};
|
|
377
|
+
function parseValuesYaml(content) {
|
|
378
|
+
const values = parseYaml3(content, yamlParseOptions3);
|
|
379
|
+
if (!isRecord2(values)) return {};
|
|
380
|
+
return values;
|
|
381
|
+
}
|
|
382
|
+
function isRecord2(value) {
|
|
383
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
384
|
+
}
|
|
385
|
+
function getValueAtPath(values, path) {
|
|
386
|
+
const parts = path.split(".").filter(Boolean);
|
|
387
|
+
let current = values;
|
|
388
|
+
for (const part of parts) {
|
|
389
|
+
if (!isRecord2(current)) return void 0;
|
|
390
|
+
current = current[part];
|
|
391
|
+
}
|
|
392
|
+
return current;
|
|
393
|
+
}
|
|
394
|
+
function getString(value) {
|
|
395
|
+
return typeof value === "string" ? value : void 0;
|
|
396
|
+
}
|
|
397
|
+
function getNumber(value) {
|
|
398
|
+
if (typeof value === "number")
|
|
399
|
+
return Number.isFinite(value) ? value : void 0;
|
|
400
|
+
if (typeof value === "string") {
|
|
401
|
+
const parsed = Number.parseFloat(value);
|
|
402
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
403
|
+
}
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
function getBoolean(value) {
|
|
407
|
+
if (typeof value === "boolean") return value;
|
|
408
|
+
if (typeof value === "string") {
|
|
409
|
+
if (value.toLowerCase() === "true") return true;
|
|
410
|
+
if (value.toLowerCase() === "false") return false;
|
|
411
|
+
}
|
|
412
|
+
return void 0;
|
|
413
|
+
}
|
|
414
|
+
function normalizeRegistry(repository, registry) {
|
|
415
|
+
if (!registry) return repository;
|
|
416
|
+
if (repository.includes("/")) return repository;
|
|
417
|
+
return `${registry}/${repository}`;
|
|
418
|
+
}
|
|
419
|
+
function extractImage(values) {
|
|
420
|
+
const image = values.image;
|
|
421
|
+
if (typeof image === "string") {
|
|
422
|
+
return image;
|
|
423
|
+
}
|
|
424
|
+
if (isRecord2(image)) {
|
|
425
|
+
const repository = getString(image.repository) ?? getString(image.name);
|
|
426
|
+
if (!repository) return void 0;
|
|
427
|
+
const registry = getString(image.registry) ?? getString(getValueAtPath(values, "global.imageRegistry"));
|
|
428
|
+
const normalizedRepo = normalizeRegistry(repository, registry);
|
|
429
|
+
const tag = getString(image.tag);
|
|
430
|
+
if (tag && !normalizedRepo.includes(":")) {
|
|
431
|
+
return `${normalizedRepo}:${tag}`;
|
|
432
|
+
}
|
|
433
|
+
return normalizedRepo;
|
|
434
|
+
}
|
|
435
|
+
return void 0;
|
|
436
|
+
}
|
|
437
|
+
function parsePortValue(value) {
|
|
438
|
+
const mappings = [];
|
|
439
|
+
if (typeof value === "number") {
|
|
440
|
+
mappings.push({ internal: value });
|
|
441
|
+
return mappings;
|
|
442
|
+
}
|
|
443
|
+
if (typeof value === "string") {
|
|
444
|
+
const parsed = Number.parseInt(value, 10);
|
|
445
|
+
if (!Number.isNaN(parsed)) {
|
|
446
|
+
mappings.push({ internal: parsed });
|
|
447
|
+
}
|
|
448
|
+
return mappings;
|
|
449
|
+
}
|
|
450
|
+
if (Array.isArray(value)) {
|
|
451
|
+
for (const entry of value) {
|
|
452
|
+
mappings.push(...parsePortValue(entry));
|
|
453
|
+
}
|
|
454
|
+
return mappings;
|
|
455
|
+
}
|
|
456
|
+
if (isRecord2(value)) {
|
|
457
|
+
const port = getNumber(value.port);
|
|
458
|
+
const targetPort = getNumber(value.targetPort) ?? getNumber(value.containerPort) ?? getNumber(value.containerPorts);
|
|
459
|
+
if (port && targetPort) {
|
|
460
|
+
mappings.push({ internal: targetPort, external: port });
|
|
461
|
+
return mappings;
|
|
462
|
+
}
|
|
463
|
+
if (targetPort) {
|
|
464
|
+
mappings.push({ internal: targetPort });
|
|
465
|
+
return mappings;
|
|
466
|
+
}
|
|
467
|
+
if (port) {
|
|
468
|
+
mappings.push({ internal: port });
|
|
469
|
+
return mappings;
|
|
470
|
+
}
|
|
471
|
+
for (const entry of Object.values(value)) {
|
|
472
|
+
mappings.push(...parsePortValue(entry));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return mappings;
|
|
476
|
+
}
|
|
477
|
+
function extractPorts(values) {
|
|
478
|
+
const ports = [];
|
|
479
|
+
const service = isRecord2(values.service) ? values.service : void 0;
|
|
480
|
+
if (service) {
|
|
481
|
+
if ("ports" in service) {
|
|
482
|
+
ports.push(...parsePortValue(service.ports));
|
|
483
|
+
}
|
|
484
|
+
if ("port" in service) {
|
|
485
|
+
ports.push(...parsePortValue(service.port));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if ("containerPorts" in values) {
|
|
489
|
+
ports.push(...parsePortValue(values.containerPorts));
|
|
490
|
+
}
|
|
491
|
+
if ("containerPort" in values) {
|
|
492
|
+
ports.push(...parsePortValue(values.containerPort));
|
|
493
|
+
}
|
|
494
|
+
if ("ports" in values) {
|
|
495
|
+
ports.push(...parsePortValue(values.ports));
|
|
496
|
+
}
|
|
497
|
+
const seen = /* @__PURE__ */ new Set();
|
|
498
|
+
const deduped = [];
|
|
499
|
+
for (const mapping of ports) {
|
|
500
|
+
const key = `${mapping.external ?? "internal"}-${mapping.internal}`;
|
|
501
|
+
if (seen.has(key)) continue;
|
|
502
|
+
seen.add(key);
|
|
503
|
+
deduped.push(mapping);
|
|
504
|
+
}
|
|
505
|
+
return deduped.length ? deduped : void 0;
|
|
506
|
+
}
|
|
507
|
+
function extractReplicas(values) {
|
|
508
|
+
const replicaCount = getNumber(values.replicaCount) ?? getNumber(values.replicas);
|
|
509
|
+
return replicaCount !== void 0 ? Math.round(replicaCount) : void 0;
|
|
510
|
+
}
|
|
511
|
+
function extractResourceRequests(values) {
|
|
512
|
+
const resources = isRecord2(values.resources) ? values.resources : void 0;
|
|
513
|
+
const requests = resources && isRecord2(resources.requests) ? resources.requests : void 0;
|
|
514
|
+
const cpu = getString(requests?.cpu);
|
|
515
|
+
const memory = getString(requests?.memory);
|
|
516
|
+
if (!cpu && !memory) return void 0;
|
|
517
|
+
return {
|
|
518
|
+
cpu: cpu || void 0,
|
|
519
|
+
memory: memory || void 0
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function extractStorageSize(values) {
|
|
523
|
+
const persistence = isRecord2(values.persistence) ? values.persistence : void 0;
|
|
524
|
+
if (!persistence) return void 0;
|
|
525
|
+
return getString(persistence.size) ?? getString(persistence.storageSize) ?? getString(persistence.storage);
|
|
526
|
+
}
|
|
527
|
+
function extractServiceConfig(values) {
|
|
528
|
+
return {
|
|
529
|
+
image: extractImage(values),
|
|
530
|
+
ports: extractPorts(values),
|
|
531
|
+
replicas: extractReplicas(values),
|
|
532
|
+
resourceRequests: extractResourceRequests(values),
|
|
533
|
+
storageSize: extractStorageSize(values)
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ../core/src/parsers/helm/components.ts
|
|
538
|
+
var SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
539
|
+
"global",
|
|
540
|
+
"image",
|
|
541
|
+
"init",
|
|
542
|
+
"service",
|
|
543
|
+
"resources",
|
|
544
|
+
"persistence",
|
|
545
|
+
"ingress",
|
|
546
|
+
"rbac",
|
|
547
|
+
"serviceAccount",
|
|
548
|
+
"nodeSelector",
|
|
549
|
+
"tolerations",
|
|
550
|
+
"affinity",
|
|
551
|
+
"autoscaling",
|
|
552
|
+
"metrics",
|
|
553
|
+
"networkPolicy",
|
|
554
|
+
"securityContext",
|
|
555
|
+
"podSecurityContext",
|
|
556
|
+
"volumePermissions",
|
|
557
|
+
"extraEnvVars",
|
|
558
|
+
"extraVolumeMounts",
|
|
559
|
+
"extraVolumes",
|
|
560
|
+
"fullnameOverride",
|
|
561
|
+
"nameOverride"
|
|
562
|
+
]);
|
|
563
|
+
function hasComponentMarkers(values) {
|
|
564
|
+
return "replicaCount" in values || "replicas" in values || "containerPorts" in values || "containerPort" in values || "service" in values && isRecord2(values.service) || "image" in values || "resources" in values;
|
|
565
|
+
}
|
|
566
|
+
function detectComponents(values, options) {
|
|
567
|
+
const components = [];
|
|
568
|
+
const dependencyNames = new Set(
|
|
569
|
+
options?.dependencyNames?.map((name) => name.toLowerCase()) ?? []
|
|
570
|
+
);
|
|
571
|
+
for (const [key, value] of Object.entries(values)) {
|
|
572
|
+
if (!isRecord2(value)) continue;
|
|
573
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
574
|
+
if (dependencyNames.has(key.toLowerCase())) continue;
|
|
575
|
+
if (key.startsWith("external")) continue;
|
|
576
|
+
const enabled = getBoolean(value.enabled);
|
|
577
|
+
if (enabled === false) continue;
|
|
578
|
+
if (!hasComponentMarkers(value)) continue;
|
|
579
|
+
components.push({ name: key, values: value });
|
|
580
|
+
}
|
|
581
|
+
return components;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ../core/src/parsers/helm/rendered.ts
|
|
585
|
+
import { execFileSync } from "child_process";
|
|
586
|
+
import { readFileSync, readdirSync } from "fs";
|
|
587
|
+
import { join } from "path";
|
|
588
|
+
import { parseAllDocuments } from "yaml";
|
|
589
|
+
var yamlParseOptions4 = {
|
|
590
|
+
maxAliasCount: -1,
|
|
591
|
+
merge: true
|
|
592
|
+
};
|
|
593
|
+
function isRecord3(value) {
|
|
594
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
595
|
+
}
|
|
596
|
+
function normalizeName(value) {
|
|
597
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
|
598
|
+
}
|
|
599
|
+
function renderHelmTemplate(chartDir, releaseName, valuesFiles = [], showOnly) {
|
|
600
|
+
try {
|
|
601
|
+
const args = ["template", releaseName, chartDir, "--namespace", "default"];
|
|
602
|
+
for (const file of valuesFiles) {
|
|
603
|
+
args.push("--values", file);
|
|
604
|
+
}
|
|
605
|
+
if (showOnly) {
|
|
606
|
+
args.push("--show-only", showOnly);
|
|
607
|
+
}
|
|
608
|
+
return execFileSync("helm", args, {
|
|
609
|
+
encoding: "utf-8",
|
|
610
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
611
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
612
|
+
});
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function listTemplateFiles(chartDir) {
|
|
618
|
+
const templatesDir = join(chartDir, "templates");
|
|
619
|
+
try {
|
|
620
|
+
const files = [];
|
|
621
|
+
const queue = [
|
|
622
|
+
{ dir: templatesDir, relativeDir: "templates" }
|
|
623
|
+
];
|
|
624
|
+
while (queue.length) {
|
|
625
|
+
const current = queue.pop();
|
|
626
|
+
if (!current) continue;
|
|
627
|
+
const dirEntries = readdirSync(current.dir, { withFileTypes: true });
|
|
628
|
+
for (const entry of dirEntries) {
|
|
629
|
+
if (entry.name.startsWith("_")) continue;
|
|
630
|
+
if (entry.name === "NOTES.txt") continue;
|
|
631
|
+
const fullPath = join(current.dir, entry.name);
|
|
632
|
+
const relPath = join(current.relativeDir, entry.name);
|
|
633
|
+
if (entry.isDirectory()) {
|
|
634
|
+
queue.push({ dir: fullPath, relativeDir: relPath });
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
|
|
638
|
+
files.push(relPath);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return files;
|
|
643
|
+
} catch {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function normalizeTemplateText(text, releaseName) {
|
|
648
|
+
let normalized = text;
|
|
649
|
+
normalized = normalized.replace(
|
|
650
|
+
/{{\s*include\s+"[^"]+\.fullname"[^}]*}}/g,
|
|
651
|
+
releaseName
|
|
652
|
+
);
|
|
653
|
+
normalized = normalized.replace(
|
|
654
|
+
/{{\s*include\s+"[^"]+\.componentname"\s*\(list\s+\$\s+"([^"]+)"\s*\)\s*}}/g,
|
|
655
|
+
`${releaseName}-$1`
|
|
656
|
+
);
|
|
657
|
+
return normalized;
|
|
658
|
+
}
|
|
659
|
+
function resolveSourceFromTemplatePath(templatePath, componentMap, aliasMap) {
|
|
660
|
+
const base = templatePath.split("/").pop() ?? templatePath;
|
|
661
|
+
const normalized = normalizeName(base.replace(/\.ya?ml$/, ""));
|
|
662
|
+
const aliasEntries = Array.from(aliasMap.entries()).sort(
|
|
663
|
+
(a, b) => b[0].length - a[0].length
|
|
664
|
+
);
|
|
665
|
+
for (const [alias, nodeId] of aliasEntries) {
|
|
666
|
+
if (normalized.includes(alias)) {
|
|
667
|
+
return nodeId;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const componentEntries = Array.from(componentMap.entries()).sort(
|
|
671
|
+
(a, b) => b[0].length - a[0].length
|
|
672
|
+
);
|
|
673
|
+
for (const [component, nodeId] of componentEntries) {
|
|
674
|
+
if (normalized.includes(component)) {
|
|
675
|
+
return nodeId;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return void 0;
|
|
679
|
+
}
|
|
680
|
+
function buildServiceTargets(releaseName, componentMap, aliasMap) {
|
|
681
|
+
const targets = /* @__PURE__ */ new Map();
|
|
682
|
+
for (const [component, nodeId] of componentMap) {
|
|
683
|
+
targets.set(`${releaseName}-${component}`, nodeId);
|
|
684
|
+
}
|
|
685
|
+
for (const [alias, nodeId] of aliasMap) {
|
|
686
|
+
targets.set(`${releaseName}-${alias}`, nodeId);
|
|
687
|
+
}
|
|
688
|
+
return targets;
|
|
689
|
+
}
|
|
690
|
+
function inferEdgesFromTemplates(options) {
|
|
691
|
+
const templates = listTemplateFiles(options.chartDir);
|
|
692
|
+
if (templates.length === 0) {
|
|
693
|
+
return {
|
|
694
|
+
edges: [],
|
|
695
|
+
externalServices: [],
|
|
696
|
+
renderedComponents: [],
|
|
697
|
+
workloadComponents: [],
|
|
698
|
+
jobComponents: []
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
const serviceTargets = buildServiceTargets(
|
|
702
|
+
options.releaseName,
|
|
703
|
+
options.componentMap,
|
|
704
|
+
options.aliasMap
|
|
705
|
+
);
|
|
706
|
+
const edges = [];
|
|
707
|
+
const edgeKeys = /* @__PURE__ */ new Set();
|
|
708
|
+
const externalServices = /* @__PURE__ */ new Map();
|
|
709
|
+
for (const template of templates) {
|
|
710
|
+
const sourceNode = resolveSourceFromTemplatePath(
|
|
711
|
+
template,
|
|
712
|
+
options.componentMap,
|
|
713
|
+
options.aliasMap
|
|
714
|
+
);
|
|
715
|
+
if (!sourceNode) continue;
|
|
716
|
+
let contents = "";
|
|
717
|
+
try {
|
|
718
|
+
contents = readFileSync(join(options.chartDir, template), "utf-8");
|
|
719
|
+
} catch {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const normalized = normalizeTemplateText(contents, options.releaseName);
|
|
723
|
+
const lines = normalized.split("\n");
|
|
724
|
+
for (const line of lines) {
|
|
725
|
+
for (const [serviceName, targetNode] of serviceTargets) {
|
|
726
|
+
if (sourceNode === targetNode) continue;
|
|
727
|
+
if (!valueIncludesService(line, serviceName)) continue;
|
|
728
|
+
const key = `${sourceNode}->${targetNode}`;
|
|
729
|
+
if (edgeKeys.has(key)) continue;
|
|
730
|
+
edgeKeys.add(key);
|
|
731
|
+
edges.push({ from: sourceNode, to: targetNode, type: "inferred" });
|
|
732
|
+
}
|
|
733
|
+
const matches = line.matchAll(/([a-zA-Z0-9.-]+):(\d{2,5})/g);
|
|
734
|
+
for (const match of matches) {
|
|
735
|
+
const host = match[1];
|
|
736
|
+
const port = Number.parseInt(match[2] ?? "", 10);
|
|
737
|
+
if (!host || Number.isNaN(port)) continue;
|
|
738
|
+
const normalizedHost = host.toLowerCase();
|
|
739
|
+
const hostBase = normalizedHost.split(".")[0] ?? normalizedHost;
|
|
740
|
+
if (!hostBase) continue;
|
|
741
|
+
if (normalizedHost === "localhost" || /^\d{1,3}(\.\d{1,3}){3}$/.test(normalizedHost) || /^\d+$/.test(hostBase)) {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (serviceTargets.has(hostBase) || serviceTargets.has(normalizedHost)) {
|
|
745
|
+
const targetNode = serviceTargets.get(hostBase) ?? serviceTargets.get(normalizedHost);
|
|
746
|
+
if (!targetNode || sourceNode === targetNode) continue;
|
|
747
|
+
const key2 = `${sourceNode}->${targetNode}`;
|
|
748
|
+
if (edgeKeys.has(key2)) continue;
|
|
749
|
+
edgeKeys.add(key2);
|
|
750
|
+
edges.push({ from: sourceNode, to: targetNode, type: "inferred" });
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const externalId = `external-${hostBase}`;
|
|
754
|
+
if (!externalServices.has(externalId)) {
|
|
755
|
+
externalServices.set(externalId, {
|
|
756
|
+
id: externalId,
|
|
757
|
+
name: hostBase,
|
|
758
|
+
port
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const key = `${sourceNode}->${externalId}`;
|
|
762
|
+
if (edgeKeys.has(key)) continue;
|
|
763
|
+
edgeKeys.add(key);
|
|
764
|
+
edges.push({ from: sourceNode, to: externalId, type: "inferred" });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
edges,
|
|
770
|
+
externalServices: Array.from(externalServices.values()),
|
|
771
|
+
renderedComponents: [],
|
|
772
|
+
workloadComponents: [],
|
|
773
|
+
jobComponents: []
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function parseRenderedResources(content) {
|
|
777
|
+
const documents = parseAllDocuments(content, yamlParseOptions4);
|
|
778
|
+
const resources = [];
|
|
779
|
+
for (const doc of documents) {
|
|
780
|
+
const data = doc.toJSON();
|
|
781
|
+
if (!isRecord3(data)) continue;
|
|
782
|
+
resources.push(data);
|
|
783
|
+
}
|
|
784
|
+
return resources;
|
|
785
|
+
}
|
|
786
|
+
function getLabels(resource) {
|
|
787
|
+
const metadataLabels = resource.metadata?.labels && isRecord3(resource.metadata.labels) ? resource.metadata.labels : {};
|
|
788
|
+
const templateLabels = (() => {
|
|
789
|
+
const spec = resource.spec;
|
|
790
|
+
if (!spec || !isRecord3(spec)) return {};
|
|
791
|
+
const template = spec.template;
|
|
792
|
+
if (!isRecord3(template)) return {};
|
|
793
|
+
const metadata = template.metadata;
|
|
794
|
+
if (!isRecord3(metadata)) return {};
|
|
795
|
+
const labels = metadata.labels;
|
|
796
|
+
if (!isRecord3(labels)) return {};
|
|
797
|
+
return labels;
|
|
798
|
+
})();
|
|
799
|
+
return { ...metadataLabels, ...templateLabels };
|
|
800
|
+
}
|
|
801
|
+
function getPodSpec(resource) {
|
|
802
|
+
const spec = resource.spec;
|
|
803
|
+
if (!spec || !isRecord3(spec)) return null;
|
|
804
|
+
switch (resource.kind) {
|
|
805
|
+
case "Deployment":
|
|
806
|
+
case "StatefulSet":
|
|
807
|
+
case "DaemonSet":
|
|
808
|
+
case "ReplicaSet":
|
|
809
|
+
if (isRecord3(spec.template) && isRecord3(spec.template.spec) && spec.template.spec) {
|
|
810
|
+
return spec.template.spec;
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
case "Job":
|
|
814
|
+
if (isRecord3(spec.template) && isRecord3(spec.template.spec) && spec.template.spec) {
|
|
815
|
+
return spec.template.spec;
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
case "CronJob": {
|
|
819
|
+
const jobTemplate = spec.jobTemplate;
|
|
820
|
+
if (isRecord3(jobTemplate) && isRecord3(jobTemplate.spec) && isRecord3(jobTemplate.spec.template) && isRecord3(jobTemplate.spec.template.spec)) {
|
|
821
|
+
return jobTemplate.spec.template.spec;
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
case "Pod":
|
|
826
|
+
if (isRecord3(spec)) {
|
|
827
|
+
return spec;
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
default:
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function collectConfigMapNames(podSpec) {
|
|
835
|
+
const names = [];
|
|
836
|
+
for (const volume of podSpec.volumes ?? []) {
|
|
837
|
+
const name = volume.configMap?.name;
|
|
838
|
+
if (name) names.push(name);
|
|
839
|
+
}
|
|
840
|
+
const containers = [
|
|
841
|
+
...podSpec.containers ?? [],
|
|
842
|
+
...podSpec.initContainers ?? []
|
|
843
|
+
];
|
|
844
|
+
for (const container of containers) {
|
|
845
|
+
for (const envFrom of container.envFrom ?? []) {
|
|
846
|
+
const name = envFrom.configMapRef?.name;
|
|
847
|
+
if (name) names.push(name);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return names;
|
|
851
|
+
}
|
|
852
|
+
function collectStringsFromPodSpec(podSpec, configMaps) {
|
|
853
|
+
const values = [];
|
|
854
|
+
const containers = [
|
|
855
|
+
...podSpec.containers ?? [],
|
|
856
|
+
...podSpec.initContainers ?? []
|
|
857
|
+
];
|
|
858
|
+
for (const container of containers) {
|
|
859
|
+
for (const env of container.env ?? []) {
|
|
860
|
+
if (typeof env.value === "string") {
|
|
861
|
+
values.push(env.value);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (Array.isArray(container.args)) {
|
|
865
|
+
values.push(
|
|
866
|
+
...container.args.filter((value) => typeof value === "string")
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
if (Array.isArray(container.command)) {
|
|
870
|
+
values.push(
|
|
871
|
+
...container.command.filter((value) => typeof value === "string")
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
for (const name of collectConfigMapNames(podSpec)) {
|
|
876
|
+
const data = configMaps.get(name);
|
|
877
|
+
if (data) {
|
|
878
|
+
values.push(...data);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return values;
|
|
882
|
+
}
|
|
883
|
+
function valueIncludesService(value, serviceName) {
|
|
884
|
+
const normalizedValue = value.toLowerCase();
|
|
885
|
+
const normalizedService = serviceName.toLowerCase();
|
|
886
|
+
const pattern = new RegExp(
|
|
887
|
+
`(^|[\\s:/"'=\\(])${normalizedService.replace(/[-/\\^$*+?.()|[\\]{}]/g, "\\$&")}(?=$|[\\s:/"'=.\\)])`,
|
|
888
|
+
"i"
|
|
889
|
+
);
|
|
890
|
+
return normalizedValue.includes(normalizedService) && pattern.test(normalizedValue);
|
|
891
|
+
}
|
|
892
|
+
function resolveNodeIdFromComponent(component, componentMap, aliasMap) {
|
|
893
|
+
if (!component) return void 0;
|
|
894
|
+
const normalized = normalizeName(component);
|
|
895
|
+
return componentMap.get(normalized) ?? aliasMap.get(normalized);
|
|
896
|
+
}
|
|
897
|
+
function resolveNodeIdFromName(resourceName, componentMap, aliasMap) {
|
|
898
|
+
if (!resourceName) return void 0;
|
|
899
|
+
const normalized = normalizeName(resourceName);
|
|
900
|
+
for (const [component, nodeId] of componentMap) {
|
|
901
|
+
if (normalized === component || normalized.endsWith(`-${component}`)) {
|
|
902
|
+
return nodeId;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
for (const [alias, nodeId] of aliasMap) {
|
|
906
|
+
if (normalized === alias || normalized.endsWith(`-${alias}`)) {
|
|
907
|
+
return nodeId;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return void 0;
|
|
911
|
+
}
|
|
912
|
+
function inferEdgesFromRenderedManifests(options) {
|
|
913
|
+
const manifest = renderHelmTemplate(
|
|
914
|
+
options.chartDir,
|
|
915
|
+
options.releaseName,
|
|
916
|
+
options.valuesFiles ?? []
|
|
917
|
+
);
|
|
918
|
+
if (!manifest) {
|
|
919
|
+
console.warn(
|
|
920
|
+
"Helm render failed; falling back to static template scanning. Some dependencies may be missing."
|
|
921
|
+
);
|
|
922
|
+
return inferEdgesFromTemplates(options);
|
|
923
|
+
}
|
|
924
|
+
const resources = parseRenderedResources(manifest);
|
|
925
|
+
const serviceTargets = /* @__PURE__ */ new Map();
|
|
926
|
+
const configMaps = /* @__PURE__ */ new Map();
|
|
927
|
+
const externalServices = /* @__PURE__ */ new Map();
|
|
928
|
+
const renderedComponents = /* @__PURE__ */ new Set();
|
|
929
|
+
const workloadComponents = /* @__PURE__ */ new Set();
|
|
930
|
+
const jobComponents = /* @__PURE__ */ new Set();
|
|
931
|
+
const longRunningKinds = /* @__PURE__ */ new Set([
|
|
932
|
+
"Deployment",
|
|
933
|
+
"StatefulSet",
|
|
934
|
+
"DaemonSet",
|
|
935
|
+
"ReplicaSet",
|
|
936
|
+
"Pod"
|
|
937
|
+
]);
|
|
938
|
+
for (const resource of resources) {
|
|
939
|
+
if (resource.kind === "ConfigMap" && resource.metadata?.name) {
|
|
940
|
+
const rawData = resource.data;
|
|
941
|
+
if (isRecord3(rawData)) {
|
|
942
|
+
const strings = Object.values(rawData).filter(
|
|
943
|
+
(value) => typeof value === "string"
|
|
944
|
+
);
|
|
945
|
+
if (strings.length > 0) {
|
|
946
|
+
configMaps.set(resource.metadata.name, strings);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const labels = getLabels(resource);
|
|
951
|
+
const componentLabel = labels["app.kubernetes.io/component"] ?? labels.app;
|
|
952
|
+
const resolvedNode = resolveNodeIdFromComponent(
|
|
953
|
+
componentLabel,
|
|
954
|
+
options.componentMap,
|
|
955
|
+
options.aliasMap
|
|
956
|
+
) ?? resolveNodeIdFromName(
|
|
957
|
+
resource.metadata?.name,
|
|
958
|
+
options.componentMap,
|
|
959
|
+
options.aliasMap
|
|
960
|
+
);
|
|
961
|
+
if (!resolvedNode) continue;
|
|
962
|
+
renderedComponents.add(resolvedNode);
|
|
963
|
+
if (resource.kind === "Service" && resource.metadata?.name) {
|
|
964
|
+
serviceTargets.set(resource.metadata.name, resolvedNode);
|
|
965
|
+
workloadComponents.add(resolvedNode);
|
|
966
|
+
}
|
|
967
|
+
if (resource.kind && longRunningKinds.has(resource.kind)) {
|
|
968
|
+
workloadComponents.add(resolvedNode);
|
|
969
|
+
}
|
|
970
|
+
if (resource.kind === "Job" || resource.kind === "CronJob") {
|
|
971
|
+
jobComponents.add(resolvedNode);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const edges = [];
|
|
975
|
+
const edgeKeys = /* @__PURE__ */ new Set();
|
|
976
|
+
const serviceNames = /* @__PURE__ */ new Set();
|
|
977
|
+
for (const [serviceName] of serviceTargets) {
|
|
978
|
+
serviceNames.add(serviceName.toLowerCase());
|
|
979
|
+
}
|
|
980
|
+
for (const resource of resources) {
|
|
981
|
+
if (![
|
|
982
|
+
"Deployment",
|
|
983
|
+
"StatefulSet",
|
|
984
|
+
"DaemonSet",
|
|
985
|
+
"ReplicaSet",
|
|
986
|
+
"Job",
|
|
987
|
+
"CronJob",
|
|
988
|
+
"Pod"
|
|
989
|
+
].includes(resource.kind ?? "")) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const labels = getLabels(resource);
|
|
993
|
+
const componentLabel = labels["app.kubernetes.io/component"] ?? labels.app;
|
|
994
|
+
const sourceNode = resolveNodeIdFromComponent(
|
|
995
|
+
componentLabel,
|
|
996
|
+
options.componentMap,
|
|
997
|
+
options.aliasMap
|
|
998
|
+
) ?? resolveNodeIdFromName(
|
|
999
|
+
resource.metadata?.name,
|
|
1000
|
+
options.componentMap,
|
|
1001
|
+
options.aliasMap
|
|
1002
|
+
);
|
|
1003
|
+
if (!sourceNode) continue;
|
|
1004
|
+
const podSpec = getPodSpec(resource);
|
|
1005
|
+
if (!podSpec) continue;
|
|
1006
|
+
const strings = collectStringsFromPodSpec(podSpec, configMaps);
|
|
1007
|
+
if (strings.length === 0) continue;
|
|
1008
|
+
for (const [serviceName, targetNode] of serviceTargets) {
|
|
1009
|
+
if (sourceNode === targetNode) continue;
|
|
1010
|
+
for (const value of strings) {
|
|
1011
|
+
if (!valueIncludesService(value, serviceName)) continue;
|
|
1012
|
+
const key = `${sourceNode}->${targetNode}`;
|
|
1013
|
+
if (edgeKeys.has(key)) continue;
|
|
1014
|
+
edgeKeys.add(key);
|
|
1015
|
+
edges.push({ from: sourceNode, to: targetNode, type: "inferred" });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
for (const value of strings) {
|
|
1019
|
+
const matches = value.matchAll(/([a-zA-Z0-9.-]+):(\d{2,5})/g);
|
|
1020
|
+
for (const match of matches) {
|
|
1021
|
+
const host = match[1];
|
|
1022
|
+
const port = Number.parseInt(match[2] ?? "", 10);
|
|
1023
|
+
if (!host || Number.isNaN(port)) continue;
|
|
1024
|
+
const normalizedHost = host.toLowerCase();
|
|
1025
|
+
const hostBase = normalizedHost.split(".")[0] ?? normalizedHost;
|
|
1026
|
+
if (!hostBase) continue;
|
|
1027
|
+
if (normalizedHost === "localhost" || /^\d{1,3}(\.\d{1,3}){3}$/.test(normalizedHost) || /^\d+$/.test(hostBase)) {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (serviceNames.has(hostBase) || serviceNames.has(normalizedHost)) {
|
|
1031
|
+
const targetNode = serviceTargets.get(hostBase) ?? serviceTargets.get(normalizedHost);
|
|
1032
|
+
if (!targetNode || sourceNode === targetNode) continue;
|
|
1033
|
+
const key2 = `${sourceNode}->${targetNode}`;
|
|
1034
|
+
if (edgeKeys.has(key2)) continue;
|
|
1035
|
+
edgeKeys.add(key2);
|
|
1036
|
+
edges.push({ from: sourceNode, to: targetNode, type: "inferred" });
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const externalId = `external-${hostBase}`;
|
|
1040
|
+
if (!externalServices.has(externalId)) {
|
|
1041
|
+
externalServices.set(externalId, {
|
|
1042
|
+
id: externalId,
|
|
1043
|
+
name: hostBase,
|
|
1044
|
+
port
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
const key = `${sourceNode}->${externalId}`;
|
|
1048
|
+
if (edgeKeys.has(key)) continue;
|
|
1049
|
+
edgeKeys.add(key);
|
|
1050
|
+
edges.push({ from: sourceNode, to: externalId, type: "inferred" });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
edges,
|
|
1056
|
+
externalServices: Array.from(externalServices.values()),
|
|
1057
|
+
renderedComponents: Array.from(renderedComponents),
|
|
1058
|
+
workloadComponents: Array.from(workloadComponents),
|
|
1059
|
+
jobComponents: Array.from(jobComponents)
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ../core/src/parsers/helm/index.ts
|
|
1064
|
+
function buildEmptyGraph(project, sourceFiles = []) {
|
|
1065
|
+
return {
|
|
1066
|
+
nodes: [],
|
|
1067
|
+
edges: [],
|
|
1068
|
+
metadata: {
|
|
1069
|
+
project,
|
|
1070
|
+
parsedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1071
|
+
sourceFiles,
|
|
1072
|
+
parserVersion: "0.1.0"
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function resolveChartPath(chartDir) {
|
|
1077
|
+
const yamlPath = join2(chartDir, "Chart.yaml");
|
|
1078
|
+
if (existsSync(yamlPath)) return yamlPath;
|
|
1079
|
+
const ymlPath = join2(chartDir, "Chart.yml");
|
|
1080
|
+
if (existsSync(ymlPath)) return ymlPath;
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
function resolveSourcePath(sourceRoot, filepath) {
|
|
1084
|
+
if (!sourceRoot) return filepath;
|
|
1085
|
+
const rel = relative(sourceRoot, filepath);
|
|
1086
|
+
return rel || filepath;
|
|
1087
|
+
}
|
|
1088
|
+
function normalizeName2(value) {
|
|
1089
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
|
1090
|
+
}
|
|
1091
|
+
function buildComponentMap(chartName, components) {
|
|
1092
|
+
const map = /* @__PURE__ */ new Map();
|
|
1093
|
+
for (const component of components) {
|
|
1094
|
+
map.set(normalizeName2(component.name), `${chartName}-${component.name}`);
|
|
1095
|
+
}
|
|
1096
|
+
return map;
|
|
1097
|
+
}
|
|
1098
|
+
function hasComponentMarkers2(values) {
|
|
1099
|
+
return "replicaCount" in values || "replicas" in values || "containerPorts" in values || "containerPort" in values || "service" in values && isRecord2(values.service) || "image" in values || "resources" in values;
|
|
1100
|
+
}
|
|
1101
|
+
function buildAliasMap(values, componentMap) {
|
|
1102
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1103
|
+
const serverNode = componentMap.get("server");
|
|
1104
|
+
if (serverNode && isRecord2(values.server)) {
|
|
1105
|
+
for (const [key, value] of Object.entries(values.server)) {
|
|
1106
|
+
if (!isRecord2(value)) continue;
|
|
1107
|
+
if (!hasComponentMarkers2(value)) continue;
|
|
1108
|
+
aliases.set(normalizeName2(key), serverNode);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return aliases;
|
|
1112
|
+
}
|
|
1113
|
+
function parseConditionPaths(condition) {
|
|
1114
|
+
if (!condition) return [];
|
|
1115
|
+
return condition.split(",").map((part) => part.trim()).filter(Boolean);
|
|
1116
|
+
}
|
|
1117
|
+
function evaluateConditions(values, condition) {
|
|
1118
|
+
const paths = parseConditionPaths(condition);
|
|
1119
|
+
if (paths.length === 0) return void 0;
|
|
1120
|
+
const results = paths.map((path) => getBoolean(getValueAtPath(values, path))).filter((val) => val !== void 0);
|
|
1121
|
+
if (results.length === 0) return void 0;
|
|
1122
|
+
if (results.some((val) => val)) return true;
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
function evaluateTags(values, tags) {
|
|
1126
|
+
if (!tags || tags.length === 0) return void 0;
|
|
1127
|
+
const results = tags.map((tag) => getBoolean(getValueAtPath(values, `tags.${tag}`))).filter((val) => val !== void 0);
|
|
1128
|
+
if (results.length === 0) return void 0;
|
|
1129
|
+
if (results.some((val) => val === false)) return false;
|
|
1130
|
+
if (results.some((val) => val === true)) return true;
|
|
1131
|
+
return void 0;
|
|
1132
|
+
}
|
|
1133
|
+
function isDependencyEnabled(dependency, values) {
|
|
1134
|
+
const conditionResult = evaluateConditions(values, dependency.condition);
|
|
1135
|
+
if (conditionResult !== void 0) return conditionResult;
|
|
1136
|
+
const tagResult = evaluateTags(values, dependency.tags);
|
|
1137
|
+
if (tagResult !== void 0) return tagResult;
|
|
1138
|
+
const explicitEnabled = getBoolean(
|
|
1139
|
+
getValueAtPath(values, `${dependency.name}.enabled`)
|
|
1140
|
+
);
|
|
1141
|
+
return explicitEnabled ?? true;
|
|
1142
|
+
}
|
|
1143
|
+
function extractExternalServiceConfig(values, dependencyName) {
|
|
1144
|
+
const nameLower = dependencyName.toLowerCase();
|
|
1145
|
+
const candidates = [];
|
|
1146
|
+
if (nameLower.includes("postgres") || nameLower.includes("postgresql")) {
|
|
1147
|
+
candidates.push(
|
|
1148
|
+
"externalDatabase",
|
|
1149
|
+
"externalPostgresql",
|
|
1150
|
+
"externalPostgres"
|
|
1151
|
+
);
|
|
1152
|
+
} else if (nameLower.includes("redis")) {
|
|
1153
|
+
candidates.push("externalRedis");
|
|
1154
|
+
} else if (nameLower.includes("mysql")) {
|
|
1155
|
+
candidates.push("externalDatabase", "externalMysql");
|
|
1156
|
+
} else if (nameLower.includes("mariadb")) {
|
|
1157
|
+
candidates.push("externalDatabase", "externalMariadb");
|
|
1158
|
+
} else if (nameLower.includes("mongo")) {
|
|
1159
|
+
candidates.push("externalMongo");
|
|
1160
|
+
} else if (nameLower.includes("elasticsearch")) {
|
|
1161
|
+
candidates.push("externalElasticsearch");
|
|
1162
|
+
} else if (nameLower.includes("opensearch")) {
|
|
1163
|
+
candidates.push("externalOpensearch");
|
|
1164
|
+
} else if (nameLower.includes("rabbitmq")) {
|
|
1165
|
+
candidates.push("externalRabbitmq");
|
|
1166
|
+
} else if (nameLower.includes("kafka")) {
|
|
1167
|
+
candidates.push("externalKafka");
|
|
1168
|
+
} else {
|
|
1169
|
+
candidates.push(`external${dependencyName}`);
|
|
1170
|
+
}
|
|
1171
|
+
const externalRoot = isRecord2(values.external) ? values.external : void 0;
|
|
1172
|
+
for (const key of candidates) {
|
|
1173
|
+
const config = getValueAtPath(values, key);
|
|
1174
|
+
if (!isRecord2(config)) continue;
|
|
1175
|
+
const host = getString(config.host) ?? getString(config.hostname);
|
|
1176
|
+
if (!host) continue;
|
|
1177
|
+
const port = getNumber(config.port) ?? getNumber(config.servicePort) ?? getNumber(config.redisPort) ?? getNumber(config.databasePort);
|
|
1178
|
+
return {
|
|
1179
|
+
name: `${dependencyName} (external)`,
|
|
1180
|
+
port: port ? Math.round(port) : void 0
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
if (externalRoot && isRecord2(externalRoot[dependencyName])) {
|
|
1184
|
+
const config = externalRoot[dependencyName];
|
|
1185
|
+
const host = getString(config.host) ?? getString(config.hostname);
|
|
1186
|
+
if (host) {
|
|
1187
|
+
const port = getNumber(config.port);
|
|
1188
|
+
return {
|
|
1189
|
+
name: `${dependencyName} (external)`,
|
|
1190
|
+
port: port ? Math.round(port) : void 0
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
function buildPortMappings(port) {
|
|
1197
|
+
if (!port) return void 0;
|
|
1198
|
+
return [{ internal: port }];
|
|
1199
|
+
}
|
|
1200
|
+
function parseHelmChart(chartDir, project, sourceRoot, options) {
|
|
1201
|
+
const chartPath = resolveChartPath(chartDir);
|
|
1202
|
+
if (!chartPath) {
|
|
1203
|
+
return buildEmptyGraph(project);
|
|
1204
|
+
}
|
|
1205
|
+
const chartContent = readFileSync2(chartPath, "utf-8");
|
|
1206
|
+
const chart = parseChartYaml(chartContent);
|
|
1207
|
+
if (!chart) {
|
|
1208
|
+
return buildEmptyGraph(project, [resolveSourcePath(sourceRoot, chartPath)]);
|
|
1209
|
+
}
|
|
1210
|
+
const valuesPath = join2(chartDir, "values.yaml");
|
|
1211
|
+
let values = {};
|
|
1212
|
+
if (existsSync(valuesPath)) {
|
|
1213
|
+
const valuesContent = readFileSync2(valuesPath, "utf-8");
|
|
1214
|
+
values = parseValuesYaml(valuesContent);
|
|
1215
|
+
}
|
|
1216
|
+
const builder = new GraphBuilder(project);
|
|
1217
|
+
builder.addSourceFile(resolveSourcePath(sourceRoot, chartPath));
|
|
1218
|
+
if (existsSync(valuesPath)) {
|
|
1219
|
+
builder.addSourceFile(resolveSourcePath(sourceRoot, valuesPath));
|
|
1220
|
+
}
|
|
1221
|
+
const dependencyNames = chart.dependencies?.map((dep) => dep.name) ?? [];
|
|
1222
|
+
const detectedComponents = detectComponents(values, { dependencyNames });
|
|
1223
|
+
const componentMap = buildComponentMap(chart.name, detectedComponents);
|
|
1224
|
+
const aliasMap = buildAliasMap(values, componentMap);
|
|
1225
|
+
const renderedInference = inferEdgesFromRenderedManifests({
|
|
1226
|
+
chartDir,
|
|
1227
|
+
releaseName: chart.name,
|
|
1228
|
+
componentMap,
|
|
1229
|
+
aliasMap,
|
|
1230
|
+
valuesFiles: options?.valuesFiles
|
|
1231
|
+
});
|
|
1232
|
+
let components = detectedComponents;
|
|
1233
|
+
if (renderedInference.renderedComponents.length > 0) {
|
|
1234
|
+
const renderedSet = new Set(renderedInference.renderedComponents);
|
|
1235
|
+
const jobSet = new Set(renderedInference.jobComponents);
|
|
1236
|
+
components = detectedComponents.filter((component) => {
|
|
1237
|
+
const nodeId = `${chart.name}-${component.name}`;
|
|
1238
|
+
if (!renderedSet.has(nodeId)) return false;
|
|
1239
|
+
if (jobSet.has(nodeId)) return false;
|
|
1240
|
+
return true;
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
const chartConfig = extractServiceConfig(values);
|
|
1244
|
+
const chartImage = chartConfig.image;
|
|
1245
|
+
const chartSource = {
|
|
1246
|
+
file: resolveSourcePath(sourceRoot, chartPath),
|
|
1247
|
+
format: "helm"
|
|
1248
|
+
};
|
|
1249
|
+
const hasComponents = components.length > 0;
|
|
1250
|
+
if (!hasComponents) {
|
|
1251
|
+
builder.addNode(
|
|
1252
|
+
chart.name,
|
|
1253
|
+
chart.name,
|
|
1254
|
+
inferServiceType(chart.name, chartImage),
|
|
1255
|
+
chartSource,
|
|
1256
|
+
{
|
|
1257
|
+
image: chartImage,
|
|
1258
|
+
ports: chartConfig.ports,
|
|
1259
|
+
replicas: chartConfig.replicas,
|
|
1260
|
+
resourceRequests: chartConfig.resourceRequests,
|
|
1261
|
+
storageSize: chartConfig.storageSize
|
|
1262
|
+
}
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const dependencyTargets = [];
|
|
1266
|
+
for (const dependency of chart.dependencies ?? []) {
|
|
1267
|
+
const enabled = isDependencyEnabled(dependency, values);
|
|
1268
|
+
if (enabled) {
|
|
1269
|
+
const depId = dependency.name;
|
|
1270
|
+
builder.addNode(
|
|
1271
|
+
depId,
|
|
1272
|
+
dependency.name,
|
|
1273
|
+
inferServiceType(dependency.name, dependency.name),
|
|
1274
|
+
chartSource
|
|
1275
|
+
);
|
|
1276
|
+
dependencyTargets.push({ id: depId, edgeType: "subchart" });
|
|
1277
|
+
} else {
|
|
1278
|
+
const external = extractExternalServiceConfig(values, dependency.name);
|
|
1279
|
+
if (external) {
|
|
1280
|
+
const externalId = `external-${dependency.name}`;
|
|
1281
|
+
builder.addNode(
|
|
1282
|
+
externalId,
|
|
1283
|
+
external.name,
|
|
1284
|
+
inferServiceType(dependency.name, dependency.name),
|
|
1285
|
+
chartSource,
|
|
1286
|
+
{
|
|
1287
|
+
external: true,
|
|
1288
|
+
ports: buildPortMappings(external.port)
|
|
1289
|
+
}
|
|
1290
|
+
);
|
|
1291
|
+
dependencyTargets.push({ id: externalId, edgeType: "depends_on" });
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (hasComponents) {
|
|
1296
|
+
for (const component of components) {
|
|
1297
|
+
const componentConfig = extractServiceConfig(component.values);
|
|
1298
|
+
const componentImage = componentConfig.image ?? chartImage;
|
|
1299
|
+
const componentName = `${chart.name}-${component.name}`;
|
|
1300
|
+
const componentType = inferServiceType(component.name, componentImage);
|
|
1301
|
+
builder.addNode(
|
|
1302
|
+
componentName,
|
|
1303
|
+
componentName,
|
|
1304
|
+
componentType,
|
|
1305
|
+
chartSource,
|
|
1306
|
+
{
|
|
1307
|
+
image: componentImage,
|
|
1308
|
+
ports: componentConfig.ports ?? chartConfig.ports,
|
|
1309
|
+
replicas: componentConfig.replicas ?? chartConfig.replicas,
|
|
1310
|
+
resourceRequests: componentConfig.resourceRequests ?? chartConfig.resourceRequests,
|
|
1311
|
+
storageSize: componentConfig.storageSize ?? chartConfig.storageSize,
|
|
1312
|
+
group: chart.name
|
|
1313
|
+
}
|
|
1314
|
+
);
|
|
1315
|
+
for (const target of dependencyTargets) {
|
|
1316
|
+
builder.addEdge(componentName, target.id, target.edgeType);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
for (const target of dependencyTargets) {
|
|
1321
|
+
builder.addEdge(chart.name, target.id, target.edgeType);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
for (const service of renderedInference.externalServices) {
|
|
1325
|
+
if (!builder.hasNode(service.id)) {
|
|
1326
|
+
builder.addNode(
|
|
1327
|
+
service.id,
|
|
1328
|
+
service.name,
|
|
1329
|
+
inferServiceType(service.name, service.name),
|
|
1330
|
+
chartSource,
|
|
1331
|
+
{
|
|
1332
|
+
external: true,
|
|
1333
|
+
ports: service.port ? [{ internal: service.port }] : void 0
|
|
1334
|
+
}
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
for (const edge of renderedInference.edges) {
|
|
1339
|
+
builder.addEdge(edge.from, edge.to, edge.type);
|
|
1340
|
+
}
|
|
1341
|
+
return builder.build();
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ../core/src/pipeline/index.ts
|
|
1345
|
+
import { join as join4 } from "path";
|
|
1346
|
+
|
|
1347
|
+
// ../core/src/elk/types.ts
|
|
1348
|
+
var ELK_LAYOUT_OPTIONS = {
|
|
1349
|
+
/** Standard left-to-right layered layout */
|
|
1350
|
+
standard: {
|
|
1351
|
+
"elk.algorithm": "layered",
|
|
1352
|
+
"elk.direction": "RIGHT",
|
|
1353
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1354
|
+
"elk.spacing.nodeNode": "50",
|
|
1355
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
|
|
1356
|
+
"elk.layered.mergeEdges": "false"
|
|
1357
|
+
},
|
|
1358
|
+
/** Semantic partitioning enabled */
|
|
1359
|
+
semantic: {
|
|
1360
|
+
"elk.algorithm": "layered",
|
|
1361
|
+
"elk.direction": "RIGHT",
|
|
1362
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1363
|
+
"elk.spacing.nodeNode": "50",
|
|
1364
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
|
|
1365
|
+
"elk.layered.mergeEdges": "false",
|
|
1366
|
+
"elk.partitioning.activate": "true"
|
|
1367
|
+
},
|
|
1368
|
+
/** Compound node (for grouping related services) */
|
|
1369
|
+
compound: {
|
|
1370
|
+
"elk.algorithm": "layered",
|
|
1371
|
+
"elk.direction": "DOWN",
|
|
1372
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
1373
|
+
"elk.spacing.nodeNode": "20",
|
|
1374
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "30",
|
|
1375
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN"
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// ../core/src/elk/convert.ts
|
|
1380
|
+
var LAYER_PARTITIONS = {
|
|
1381
|
+
entry: 0,
|
|
1382
|
+
// Proxies, load balancers, external-facing
|
|
1383
|
+
ui: 1,
|
|
1384
|
+
// Web frontends, UI services
|
|
1385
|
+
api: 2,
|
|
1386
|
+
// API services, gateways, streaming
|
|
1387
|
+
worker: 3,
|
|
1388
|
+
// Background job processors (sidekiq, celery)
|
|
1389
|
+
queue: 4,
|
|
1390
|
+
// Message queues, brokers
|
|
1391
|
+
data: 5
|
|
1392
|
+
// Databases, caches, storage
|
|
1393
|
+
};
|
|
1394
|
+
var DEFAULT_NODE_SIZE = { width: 140, height: 50 };
|
|
1395
|
+
var NODE_SIZES = {
|
|
1396
|
+
database: { width: 160, height: 80 },
|
|
1397
|
+
cache: { width: 140, height: 70 },
|
|
1398
|
+
storage: { width: 160, height: 80 },
|
|
1399
|
+
queue: { width: 120, height: 60 },
|
|
1400
|
+
proxy: { width: 100, height: 50 }
|
|
1401
|
+
};
|
|
1402
|
+
function parseCpuCores(value) {
|
|
1403
|
+
if (!value) return void 0;
|
|
1404
|
+
if (value.endsWith("m")) {
|
|
1405
|
+
const milli = Number.parseFloat(value.slice(0, -1));
|
|
1406
|
+
return Number.isFinite(milli) ? milli / 1e3 : void 0;
|
|
1407
|
+
}
|
|
1408
|
+
const cores = Number.parseFloat(value);
|
|
1409
|
+
return Number.isFinite(cores) ? cores : void 0;
|
|
1410
|
+
}
|
|
1411
|
+
function parseMemoryMi(value) {
|
|
1412
|
+
if (!value) return void 0;
|
|
1413
|
+
const match = value.match(/^(\d+(?:\.\d+)?)([a-zA-Z]+)?$/);
|
|
1414
|
+
if (!match) return void 0;
|
|
1415
|
+
const amount = Number.parseFloat(match[1] ?? "");
|
|
1416
|
+
if (!Number.isFinite(amount)) return void 0;
|
|
1417
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
1418
|
+
switch (unit) {
|
|
1419
|
+
case "ki":
|
|
1420
|
+
return amount / 1024;
|
|
1421
|
+
case "mi":
|
|
1422
|
+
return amount;
|
|
1423
|
+
case "gi":
|
|
1424
|
+
return amount * 1024;
|
|
1425
|
+
case "ti":
|
|
1426
|
+
return amount * 1024 * 1024;
|
|
1427
|
+
case "k":
|
|
1428
|
+
return amount / 1024;
|
|
1429
|
+
case "m":
|
|
1430
|
+
return amount;
|
|
1431
|
+
case "g":
|
|
1432
|
+
return amount * 1024;
|
|
1433
|
+
default:
|
|
1434
|
+
return amount;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function getResourceScale(node) {
|
|
1438
|
+
const cpu = parseCpuCores(node.resourceRequests?.cpu);
|
|
1439
|
+
const memory = parseMemoryMi(node.resourceRequests?.memory);
|
|
1440
|
+
if (cpu === void 0 && memory === void 0) return 1;
|
|
1441
|
+
if ((cpu ?? 0) >= 2 || (memory ?? 0) >= 2048) return 1.4;
|
|
1442
|
+
if ((cpu ?? 0) >= 1 || (memory ?? 0) >= 1024) return 1.25;
|
|
1443
|
+
if ((cpu ?? 0) >= 0.5 || (memory ?? 0) >= 512) return 1.1;
|
|
1444
|
+
return 1;
|
|
1445
|
+
}
|
|
1446
|
+
function getSemanticLayer(node) {
|
|
1447
|
+
switch (node.type) {
|
|
1448
|
+
case "proxy":
|
|
1449
|
+
return "entry";
|
|
1450
|
+
case "ui":
|
|
1451
|
+
return "ui";
|
|
1452
|
+
case "database":
|
|
1453
|
+
case "storage":
|
|
1454
|
+
return "data";
|
|
1455
|
+
case "cache":
|
|
1456
|
+
return "data";
|
|
1457
|
+
case "queue":
|
|
1458
|
+
return "queue";
|
|
1459
|
+
}
|
|
1460
|
+
const nameLower = node.name.toLowerCase();
|
|
1461
|
+
if (nameLower.includes("nginx") || nameLower.includes("haproxy") || nameLower.includes("traefik") || nameLower.includes("ingress") || nameLower.includes("caddy")) {
|
|
1462
|
+
return "entry";
|
|
1463
|
+
}
|
|
1464
|
+
if (nameLower === "web" || nameLower.includes("frontend") || nameLower.includes("dashboard") || nameLower.includes("ui") || nameLower.includes("client") || nameLower.includes("app")) {
|
|
1465
|
+
return "ui";
|
|
1466
|
+
}
|
|
1467
|
+
if (nameLower.includes("sidekiq") || nameLower.includes("celery") || nameLower.includes("resque") || nameLower.includes("worker") || nameLower.includes("job") || nameLower.includes("consumer") || nameLower.includes("processor")) {
|
|
1468
|
+
return "worker";
|
|
1469
|
+
}
|
|
1470
|
+
if (nameLower.includes("api") || nameLower.includes("gateway") || nameLower.includes("relay") || nameLower.includes("streaming") || nameLower.includes("graphql") || nameLower.includes("grpc")) {
|
|
1471
|
+
return "api";
|
|
1472
|
+
}
|
|
1473
|
+
if (nameLower.includes("seaweedfs") || nameLower.includes("objectstorage") || nameLower.includes("minio") || nameLower.includes("s3")) {
|
|
1474
|
+
return "data";
|
|
1475
|
+
}
|
|
1476
|
+
return "api";
|
|
1477
|
+
}
|
|
1478
|
+
function getNodeSize(node) {
|
|
1479
|
+
const base = NODE_SIZES[node.type] ?? DEFAULT_NODE_SIZE;
|
|
1480
|
+
const scale = getResourceScale(node);
|
|
1481
|
+
return {
|
|
1482
|
+
width: Math.round(base.width * scale),
|
|
1483
|
+
height: Math.round(base.height * scale)
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
function getLabelDimensions(text) {
|
|
1487
|
+
return {
|
|
1488
|
+
width: Math.max(40, text.length * 8),
|
|
1489
|
+
height: 20
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function convertNode(node, layer, enablePartitioning) {
|
|
1493
|
+
const size = getNodeSize(node);
|
|
1494
|
+
const labelDims = getLabelDimensions(node.name);
|
|
1495
|
+
const elkNode = {
|
|
1496
|
+
id: node.id,
|
|
1497
|
+
width: Math.max(size.width, labelDims.width + 20),
|
|
1498
|
+
height: size.height,
|
|
1499
|
+
labels: [
|
|
1500
|
+
{
|
|
1501
|
+
text: node.name,
|
|
1502
|
+
width: labelDims.width,
|
|
1503
|
+
height: labelDims.height
|
|
1504
|
+
}
|
|
1505
|
+
]
|
|
1506
|
+
};
|
|
1507
|
+
if (enablePartitioning) {
|
|
1508
|
+
elkNode.layoutOptions = {
|
|
1509
|
+
"elk.partitioning.partition": String(LAYER_PARTITIONS[layer])
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
return elkNode;
|
|
1513
|
+
}
|
|
1514
|
+
function infraGraphToElk(graph, options = {}) {
|
|
1515
|
+
const enablePartitioning = options.semanticLayers !== false;
|
|
1516
|
+
const nodeTypeMap = /* @__PURE__ */ new Map();
|
|
1517
|
+
for (const node of graph.nodes) {
|
|
1518
|
+
nodeTypeMap.set(node.id, node.type);
|
|
1519
|
+
}
|
|
1520
|
+
const cacheNodes = /* @__PURE__ */ new Set();
|
|
1521
|
+
for (const node of graph.nodes) {
|
|
1522
|
+
if (node.type === "cache") {
|
|
1523
|
+
cacheNodes.add(node.id);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const nodePorts = /* @__PURE__ */ new Map();
|
|
1527
|
+
const layerAssignments = /* @__PURE__ */ new Map();
|
|
1528
|
+
for (const node of graph.nodes) {
|
|
1529
|
+
layerAssignments.set(node.id, getSemanticLayer(node));
|
|
1530
|
+
}
|
|
1531
|
+
const edges = graph.edges.map((edge, index) => {
|
|
1532
|
+
const edgeId = `e${index}`;
|
|
1533
|
+
const targetIsCache = cacheNodes.has(edge.to);
|
|
1534
|
+
if (targetIsCache) {
|
|
1535
|
+
const sourcePortId = `${edge.from}-south-${index}`;
|
|
1536
|
+
const targetPortId = `${edge.to}-north-${index}`;
|
|
1537
|
+
if (!nodePorts.has(edge.from)) {
|
|
1538
|
+
nodePorts.set(edge.from, { south: [], north: [] });
|
|
1539
|
+
}
|
|
1540
|
+
nodePorts.get(edge.from).south.push(sourcePortId);
|
|
1541
|
+
if (!nodePorts.has(edge.to)) {
|
|
1542
|
+
nodePorts.set(edge.to, { south: [], north: [] });
|
|
1543
|
+
}
|
|
1544
|
+
nodePorts.get(edge.to).north.push(targetPortId);
|
|
1545
|
+
return {
|
|
1546
|
+
id: edgeId,
|
|
1547
|
+
sources: [sourcePortId],
|
|
1548
|
+
targets: [targetPortId]
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
return {
|
|
1552
|
+
id: edgeId,
|
|
1553
|
+
sources: [edge.from],
|
|
1554
|
+
targets: [edge.to]
|
|
1555
|
+
};
|
|
1556
|
+
});
|
|
1557
|
+
const children = graph.nodes.map((node) => {
|
|
1558
|
+
const layer = layerAssignments.get(node.id) ?? "api";
|
|
1559
|
+
const elkNode = convertNode(node, layer, enablePartitioning);
|
|
1560
|
+
const ports = nodePorts.get(node.id);
|
|
1561
|
+
if (ports) {
|
|
1562
|
+
const elkPorts = [];
|
|
1563
|
+
for (const portId of ports.south) {
|
|
1564
|
+
elkPorts.push({
|
|
1565
|
+
id: portId,
|
|
1566
|
+
layoutOptions: { "elk.port.side": "SOUTH" }
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
for (const portId of ports.north) {
|
|
1570
|
+
elkPorts.push({
|
|
1571
|
+
id: portId,
|
|
1572
|
+
layoutOptions: { "elk.port.side": "NORTH" }
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
if (elkPorts.length > 0) {
|
|
1576
|
+
elkNode.ports = elkPorts;
|
|
1577
|
+
elkNode.layoutOptions = {
|
|
1578
|
+
...elkNode.layoutOptions,
|
|
1579
|
+
"elk.portConstraints": "FIXED_SIDE"
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return elkNode;
|
|
1584
|
+
});
|
|
1585
|
+
const layoutOptions = enablePartitioning ? ELK_LAYOUT_OPTIONS.semantic : ELK_LAYOUT_OPTIONS.standard;
|
|
1586
|
+
const elkGraph = {
|
|
1587
|
+
id: "root",
|
|
1588
|
+
layoutOptions: { ...layoutOptions },
|
|
1589
|
+
children,
|
|
1590
|
+
edges
|
|
1591
|
+
};
|
|
1592
|
+
return {
|
|
1593
|
+
graph: elkGraph,
|
|
1594
|
+
layerAssignments
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ../core/src/elk/layout.ts
|
|
1599
|
+
import ELK from "elkjs";
|
|
1600
|
+
async function runLayout(graph) {
|
|
1601
|
+
const elk = new ELK();
|
|
1602
|
+
const result = await elk.layout(graph);
|
|
1603
|
+
return {
|
|
1604
|
+
graph: result,
|
|
1605
|
+
width: result.width ?? 0,
|
|
1606
|
+
height: result.height ?? 0
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ../core/src/excalidraw/types.ts
|
|
1611
|
+
var SERVICE_COLORS = {
|
|
1612
|
+
database: {
|
|
1613
|
+
stroke: "#1971c2",
|
|
1614
|
+
background: "#a5d8ff"
|
|
1615
|
+
},
|
|
1616
|
+
cache: {
|
|
1617
|
+
stroke: "#e03131",
|
|
1618
|
+
background: "#ffc9c9"
|
|
1619
|
+
},
|
|
1620
|
+
queue: {
|
|
1621
|
+
stroke: "#f08c00",
|
|
1622
|
+
background: "#ffec99"
|
|
1623
|
+
},
|
|
1624
|
+
storage: {
|
|
1625
|
+
stroke: "#2f9e44",
|
|
1626
|
+
background: "#b2f2bb"
|
|
1627
|
+
},
|
|
1628
|
+
proxy: {
|
|
1629
|
+
stroke: "#7950f2",
|
|
1630
|
+
background: "#d0bfff"
|
|
1631
|
+
},
|
|
1632
|
+
container: {
|
|
1633
|
+
stroke: "#495057",
|
|
1634
|
+
background: "#dee2e6"
|
|
1635
|
+
},
|
|
1636
|
+
ui: {
|
|
1637
|
+
stroke: "#0c8599",
|
|
1638
|
+
background: "#99e9f2"
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
var SERVICE_SHAPES = {
|
|
1642
|
+
database: "ellipse",
|
|
1643
|
+
cache: "ellipse",
|
|
1644
|
+
storage: "ellipse",
|
|
1645
|
+
queue: "diamond",
|
|
1646
|
+
proxy: "rectangle",
|
|
1647
|
+
container: "rectangle",
|
|
1648
|
+
ui: "rectangle"
|
|
1649
|
+
};
|
|
1650
|
+
var LAYOUT_CONFIG = {
|
|
1651
|
+
nodeWidth: 180,
|
|
1652
|
+
nodeHeight: 80,
|
|
1653
|
+
horizontalGap: 100,
|
|
1654
|
+
verticalGap: 80,
|
|
1655
|
+
textPadding: 10,
|
|
1656
|
+
fontSize: 16,
|
|
1657
|
+
fontFamily: 1
|
|
1658
|
+
// 1 = Virgil (hand-drawn), 2 = Helvetica, 3 = Cascadia
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
// ../core/src/excalidraw/elk-render.ts
|
|
1662
|
+
function generateSeed() {
|
|
1663
|
+
return Math.floor(Math.random() * 2147483647);
|
|
1664
|
+
}
|
|
1665
|
+
function getServiceColors(type) {
|
|
1666
|
+
return SERVICE_COLORS[type] ?? {
|
|
1667
|
+
stroke: "#495057",
|
|
1668
|
+
background: "#dee2e6"
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function getShapeType(serviceType) {
|
|
1672
|
+
const shape = SERVICE_SHAPES[serviceType];
|
|
1673
|
+
if (shape === "ellipse" || shape === "diamond" || shape === "rectangle") {
|
|
1674
|
+
return shape;
|
|
1675
|
+
}
|
|
1676
|
+
return "rectangle";
|
|
1677
|
+
}
|
|
1678
|
+
function createNodeElement(node, elkNode) {
|
|
1679
|
+
const colors = getServiceColors(node.type);
|
|
1680
|
+
const textId = `${node.id}-text`;
|
|
1681
|
+
const shapeType = getShapeType(node.type);
|
|
1682
|
+
return {
|
|
1683
|
+
id: node.id,
|
|
1684
|
+
type: shapeType,
|
|
1685
|
+
x: elkNode.x ?? 0,
|
|
1686
|
+
y: elkNode.y ?? 0,
|
|
1687
|
+
width: elkNode.width ?? 140,
|
|
1688
|
+
height: elkNode.height ?? 50,
|
|
1689
|
+
angle: 0,
|
|
1690
|
+
strokeColor: colors.stroke,
|
|
1691
|
+
backgroundColor: colors.background,
|
|
1692
|
+
fillStyle: "solid",
|
|
1693
|
+
strokeWidth: 2,
|
|
1694
|
+
strokeStyle: node.external ? "dashed" : "solid",
|
|
1695
|
+
roughness: 1,
|
|
1696
|
+
opacity: 100,
|
|
1697
|
+
groupIds: [],
|
|
1698
|
+
frameId: null,
|
|
1699
|
+
roundness: shapeType === "rectangle" ? { type: 3 } : null,
|
|
1700
|
+
seed: generateSeed(),
|
|
1701
|
+
version: 1,
|
|
1702
|
+
versionNonce: generateSeed(),
|
|
1703
|
+
isDeleted: false,
|
|
1704
|
+
boundElements: [{ id: textId, type: "text" }],
|
|
1705
|
+
updated: Date.now(),
|
|
1706
|
+
link: null,
|
|
1707
|
+
locked: false
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
function createNodeText(node, elkNode) {
|
|
1711
|
+
const x = elkNode.x ?? 0;
|
|
1712
|
+
const y = elkNode.y ?? 0;
|
|
1713
|
+
const width = elkNode.width ?? 140;
|
|
1714
|
+
const height = elkNode.height ?? 50;
|
|
1715
|
+
const fontSize = LAYOUT_CONFIG.fontSize;
|
|
1716
|
+
const lineHeight = 1.25;
|
|
1717
|
+
const textHeight = fontSize * lineHeight;
|
|
1718
|
+
const avgCharWidth = fontSize * 0.6;
|
|
1719
|
+
const textWidth = Math.min(node.name.length * avgCharWidth, width - 20);
|
|
1720
|
+
return {
|
|
1721
|
+
id: `${node.id}-text`,
|
|
1722
|
+
type: "text",
|
|
1723
|
+
x: x + (width - textWidth) / 2,
|
|
1724
|
+
y: y + (height - textHeight) / 2,
|
|
1725
|
+
width: textWidth,
|
|
1726
|
+
height: textHeight,
|
|
1727
|
+
angle: 0,
|
|
1728
|
+
strokeColor: "#1e1e1e",
|
|
1729
|
+
backgroundColor: "transparent",
|
|
1730
|
+
fillStyle: "solid",
|
|
1731
|
+
strokeWidth: 1,
|
|
1732
|
+
strokeStyle: "solid",
|
|
1733
|
+
roughness: 1,
|
|
1734
|
+
opacity: 100,
|
|
1735
|
+
groupIds: [],
|
|
1736
|
+
frameId: null,
|
|
1737
|
+
roundness: null,
|
|
1738
|
+
seed: generateSeed(),
|
|
1739
|
+
version: 1,
|
|
1740
|
+
versionNonce: generateSeed(),
|
|
1741
|
+
isDeleted: false,
|
|
1742
|
+
boundElements: null,
|
|
1743
|
+
updated: Date.now(),
|
|
1744
|
+
link: null,
|
|
1745
|
+
locked: false,
|
|
1746
|
+
text: node.name,
|
|
1747
|
+
fontSize,
|
|
1748
|
+
fontFamily: LAYOUT_CONFIG.fontFamily,
|
|
1749
|
+
textAlign: "center",
|
|
1750
|
+
verticalAlign: "middle",
|
|
1751
|
+
baseline: fontSize,
|
|
1752
|
+
containerId: node.id,
|
|
1753
|
+
originalText: node.name,
|
|
1754
|
+
autoResize: true,
|
|
1755
|
+
lineHeight
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
function elkSectionToArrowPoints(section) {
|
|
1759
|
+
const startX = section.startPoint.x;
|
|
1760
|
+
const startY = section.startPoint.y;
|
|
1761
|
+
const points = [[0, 0]];
|
|
1762
|
+
if (section.bendPoints) {
|
|
1763
|
+
for (const bend of section.bendPoints) {
|
|
1764
|
+
points.push([bend.x - startX, bend.y - startY]);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
points.push([section.endPoint.x - startX, section.endPoint.y - startY]);
|
|
1768
|
+
return { startX, startY, points };
|
|
1769
|
+
}
|
|
1770
|
+
function extractNodeId(portOrNodeId) {
|
|
1771
|
+
const portPattern = /^(.+)-(north|south|east|west)-\d+$/;
|
|
1772
|
+
const match = portOrNodeId.match(portPattern);
|
|
1773
|
+
return match ? match[1] : portOrNodeId;
|
|
1774
|
+
}
|
|
1775
|
+
function calculateFixedPoint(connectionPoint, elkNode) {
|
|
1776
|
+
const nodeX = elkNode.x ?? 0;
|
|
1777
|
+
const nodeY = elkNode.y ?? 0;
|
|
1778
|
+
const nodeWidth = elkNode.width ?? 140;
|
|
1779
|
+
const nodeHeight = elkNode.height ?? 50;
|
|
1780
|
+
let normalizedX = (connectionPoint.x - nodeX) / nodeWidth;
|
|
1781
|
+
let normalizedY = (connectionPoint.y - nodeY) / nodeHeight;
|
|
1782
|
+
normalizedX = Math.max(0, Math.min(1, normalizedX));
|
|
1783
|
+
normalizedY = Math.max(0, Math.min(1, normalizedY));
|
|
1784
|
+
return [normalizedX, normalizedY];
|
|
1785
|
+
}
|
|
1786
|
+
function createArrowElement(edgeId, sourcePortOrNodeId, targetPortOrNodeId, sections, elkNodeMap) {
|
|
1787
|
+
if (!sections || sections.length === 0) {
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
const section = sections[0];
|
|
1791
|
+
if (!section) return null;
|
|
1792
|
+
const { startX, startY, points } = elkSectionToArrowPoints(section);
|
|
1793
|
+
const sourceNodeId = extractNodeId(sourcePortOrNodeId);
|
|
1794
|
+
const targetNodeId = extractNodeId(targetPortOrNodeId);
|
|
1795
|
+
const sourceNode = elkNodeMap.get(sourceNodeId);
|
|
1796
|
+
const targetNode = elkNodeMap.get(targetNodeId);
|
|
1797
|
+
const startFixedPoint = sourceNode ? calculateFixedPoint(section.startPoint, sourceNode) : null;
|
|
1798
|
+
const endFixedPoint = targetNode ? calculateFixedPoint(section.endPoint, targetNode) : null;
|
|
1799
|
+
return {
|
|
1800
|
+
id: edgeId,
|
|
1801
|
+
type: "arrow",
|
|
1802
|
+
x: startX,
|
|
1803
|
+
y: startY,
|
|
1804
|
+
width: Math.abs(points[points.length - 1]?.[0] ?? 0),
|
|
1805
|
+
height: Math.abs(points[points.length - 1]?.[1] ?? 0),
|
|
1806
|
+
angle: 0,
|
|
1807
|
+
strokeColor: "#868e96",
|
|
1808
|
+
backgroundColor: "transparent",
|
|
1809
|
+
fillStyle: "solid",
|
|
1810
|
+
strokeWidth: 2,
|
|
1811
|
+
strokeStyle: "solid",
|
|
1812
|
+
roughness: 1,
|
|
1813
|
+
opacity: 100,
|
|
1814
|
+
groupIds: [],
|
|
1815
|
+
frameId: null,
|
|
1816
|
+
roundness: { type: 2 },
|
|
1817
|
+
seed: generateSeed(),
|
|
1818
|
+
version: 1,
|
|
1819
|
+
versionNonce: generateSeed(),
|
|
1820
|
+
isDeleted: false,
|
|
1821
|
+
boundElements: null,
|
|
1822
|
+
updated: Date.now(),
|
|
1823
|
+
link: null,
|
|
1824
|
+
locked: false,
|
|
1825
|
+
points,
|
|
1826
|
+
startBinding: {
|
|
1827
|
+
elementId: sourceNodeId,
|
|
1828
|
+
focus: 0,
|
|
1829
|
+
gap: 1,
|
|
1830
|
+
fixedPoint: startFixedPoint
|
|
1831
|
+
},
|
|
1832
|
+
endBinding: {
|
|
1833
|
+
elementId: targetNodeId,
|
|
1834
|
+
focus: 0,
|
|
1835
|
+
gap: 1,
|
|
1836
|
+
fixedPoint: endFixedPoint
|
|
1837
|
+
},
|
|
1838
|
+
startArrowhead: null,
|
|
1839
|
+
endArrowhead: "arrow",
|
|
1840
|
+
elbowed: true
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
function buildElkNodeMap(elkGraph) {
|
|
1844
|
+
const map = /* @__PURE__ */ new Map();
|
|
1845
|
+
function addNodes(nodes) {
|
|
1846
|
+
if (!nodes) return;
|
|
1847
|
+
for (const node of nodes) {
|
|
1848
|
+
map.set(node.id, node);
|
|
1849
|
+
if (node.children) {
|
|
1850
|
+
addNodes(node.children);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
addNodes(elkGraph.children);
|
|
1855
|
+
return map;
|
|
1856
|
+
}
|
|
1857
|
+
function renderWithElkLayout(graph, elkGraph, options = {}) {
|
|
1858
|
+
const padding = options.padding ?? 50;
|
|
1859
|
+
const elements = [];
|
|
1860
|
+
const elkNodeMap = buildElkNodeMap(elkGraph);
|
|
1861
|
+
const offsetElkNodeMap = /* @__PURE__ */ new Map();
|
|
1862
|
+
for (const node of graph.nodes) {
|
|
1863
|
+
const elkNode = elkNodeMap.get(node.id);
|
|
1864
|
+
if (!elkNode || elkNode.x === void 0 || elkNode.y === void 0) {
|
|
1865
|
+
console.warn(`No ELK position for node: ${node.id}`);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
const offsetNode = {
|
|
1869
|
+
...elkNode,
|
|
1870
|
+
x: (elkNode.x ?? 0) + padding,
|
|
1871
|
+
y: (elkNode.y ?? 0) + padding
|
|
1872
|
+
};
|
|
1873
|
+
offsetElkNodeMap.set(node.id, offsetNode);
|
|
1874
|
+
elements.push(createNodeElement(node, offsetNode));
|
|
1875
|
+
elements.push(createNodeText(node, offsetNode));
|
|
1876
|
+
}
|
|
1877
|
+
if (elkGraph.edges) {
|
|
1878
|
+
for (const edge of elkGraph.edges) {
|
|
1879
|
+
const sourceId = edge.sources[0];
|
|
1880
|
+
const targetId = edge.targets[0];
|
|
1881
|
+
if (!sourceId || !targetId) continue;
|
|
1882
|
+
const offsetSections = edge.sections?.map((section) => ({
|
|
1883
|
+
...section,
|
|
1884
|
+
startPoint: {
|
|
1885
|
+
x: section.startPoint.x + padding,
|
|
1886
|
+
y: section.startPoint.y + padding
|
|
1887
|
+
},
|
|
1888
|
+
endPoint: {
|
|
1889
|
+
x: section.endPoint.x + padding,
|
|
1890
|
+
y: section.endPoint.y + padding
|
|
1891
|
+
},
|
|
1892
|
+
bendPoints: section.bendPoints?.map((bp) => ({
|
|
1893
|
+
x: bp.x + padding,
|
|
1894
|
+
y: bp.y + padding
|
|
1895
|
+
}))
|
|
1896
|
+
}));
|
|
1897
|
+
const arrow = createArrowElement(
|
|
1898
|
+
edge.id,
|
|
1899
|
+
sourceId,
|
|
1900
|
+
targetId,
|
|
1901
|
+
offsetSections ?? [],
|
|
1902
|
+
offsetElkNodeMap
|
|
1903
|
+
);
|
|
1904
|
+
if (arrow) {
|
|
1905
|
+
elements.push(arrow);
|
|
1906
|
+
const sourceNodeId = extractNodeId(sourceId);
|
|
1907
|
+
const targetNodeId = extractNodeId(targetId);
|
|
1908
|
+
const sourceEl = elements.find((e) => e.id === sourceNodeId);
|
|
1909
|
+
const targetEl = elements.find((e) => e.id === targetNodeId);
|
|
1910
|
+
if (sourceEl && "boundElements" in sourceEl) {
|
|
1911
|
+
sourceEl.boundElements = [
|
|
1912
|
+
...sourceEl.boundElements ?? [],
|
|
1913
|
+
{ id: edge.id, type: "arrow" }
|
|
1914
|
+
];
|
|
1915
|
+
}
|
|
1916
|
+
if (targetEl && "boundElements" in targetEl) {
|
|
1917
|
+
targetEl.boundElements = [
|
|
1918
|
+
...targetEl.boundElements ?? [],
|
|
1919
|
+
{ id: edge.id, type: "arrow" }
|
|
1920
|
+
];
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
type: "excalidraw",
|
|
1927
|
+
version: 2,
|
|
1928
|
+
source: "clarity-elk",
|
|
1929
|
+
elements,
|
|
1930
|
+
appState: {
|
|
1931
|
+
viewBackgroundColor: "#ffffff",
|
|
1932
|
+
gridSize: null
|
|
1933
|
+
},
|
|
1934
|
+
files: {}
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// ../core/src/llm/client.ts
|
|
1939
|
+
var OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
1940
|
+
var DEFAULT_MODEL = "anthropic/claude-opus-4.5";
|
|
1941
|
+
var DEFAULT_MAX_TOKENS = 4096;
|
|
1942
|
+
async function sendMessage(apiKey, prompt, config) {
|
|
1943
|
+
const messages = [
|
|
1944
|
+
{
|
|
1945
|
+
role: "user",
|
|
1946
|
+
content: prompt
|
|
1947
|
+
}
|
|
1948
|
+
];
|
|
1949
|
+
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
|
1950
|
+
method: "POST",
|
|
1951
|
+
headers: {
|
|
1952
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1953
|
+
"Content-Type": "application/json",
|
|
1954
|
+
"HTTP-Referer": "https://github.com/clarity-diagrams/clarity",
|
|
1955
|
+
"X-Title": "Clarity Diagrams"
|
|
1956
|
+
},
|
|
1957
|
+
body: JSON.stringify({
|
|
1958
|
+
model: config?.model ?? DEFAULT_MODEL,
|
|
1959
|
+
max_tokens: config?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
1960
|
+
messages
|
|
1961
|
+
})
|
|
1962
|
+
});
|
|
1963
|
+
if (!response.ok) {
|
|
1964
|
+
const errorText = await response.text();
|
|
1965
|
+
throw new Error(`OpenRouter API error: ${response.status} ${errorText}`);
|
|
1966
|
+
}
|
|
1967
|
+
const data = await response.json();
|
|
1968
|
+
if (data.error) {
|
|
1969
|
+
throw new Error(`OpenRouter error: ${data.error.message}`);
|
|
1970
|
+
}
|
|
1971
|
+
const content = data.choices[0]?.message?.content;
|
|
1972
|
+
if (!content) {
|
|
1973
|
+
throw new Error("No response content from OpenRouter");
|
|
1974
|
+
}
|
|
1975
|
+
return content;
|
|
1976
|
+
}
|
|
1977
|
+
function findBalancedJson(text, startChar, endChar) {
|
|
1978
|
+
const startIdx = text.indexOf(startChar);
|
|
1979
|
+
if (startIdx === -1) return null;
|
|
1980
|
+
let depth = 0;
|
|
1981
|
+
let inString = false;
|
|
1982
|
+
let escapeNext = false;
|
|
1983
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
1984
|
+
const char = text[i];
|
|
1985
|
+
if (escapeNext) {
|
|
1986
|
+
escapeNext = false;
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
if (char === "\\") {
|
|
1990
|
+
escapeNext = true;
|
|
1991
|
+
continue;
|
|
1992
|
+
}
|
|
1993
|
+
if (char === '"') {
|
|
1994
|
+
inString = !inString;
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
if (inString) continue;
|
|
1998
|
+
if (char === startChar) {
|
|
1999
|
+
depth++;
|
|
2000
|
+
} else if (char === endChar) {
|
|
2001
|
+
depth--;
|
|
2002
|
+
if (depth === 0) {
|
|
2003
|
+
return text.slice(startIdx, i + 1);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
function parseJsonResponse(response) {
|
|
2010
|
+
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
2011
|
+
const jsonString = jsonMatch ? jsonMatch[1]?.trim() : response.trim();
|
|
2012
|
+
if (!jsonString) {
|
|
2013
|
+
throw new Error("Empty response from LLM");
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
return JSON.parse(jsonString);
|
|
2017
|
+
} catch {
|
|
2018
|
+
const objectIdx = response.indexOf("{");
|
|
2019
|
+
const arrayIdx = response.indexOf("[");
|
|
2020
|
+
const tryObjectFirst = objectIdx !== -1 && (arrayIdx === -1 || objectIdx < arrayIdx);
|
|
2021
|
+
if (tryObjectFirst) {
|
|
2022
|
+
const objectJson = findBalancedJson(response, "{", "}");
|
|
2023
|
+
if (objectJson) {
|
|
2024
|
+
try {
|
|
2025
|
+
return JSON.parse(objectJson);
|
|
2026
|
+
} catch {
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const arrayJson = findBalancedJson(response, "[", "]");
|
|
2030
|
+
if (arrayJson) {
|
|
2031
|
+
try {
|
|
2032
|
+
return JSON.parse(arrayJson);
|
|
2033
|
+
} catch {
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
} else {
|
|
2037
|
+
const arrayJson = findBalancedJson(response, "[", "]");
|
|
2038
|
+
if (arrayJson) {
|
|
2039
|
+
try {
|
|
2040
|
+
return JSON.parse(arrayJson);
|
|
2041
|
+
} catch {
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const objectJson = findBalancedJson(response, "{", "}");
|
|
2045
|
+
if (objectJson) {
|
|
2046
|
+
try {
|
|
2047
|
+
return JSON.parse(objectJson);
|
|
2048
|
+
} catch {
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
throw new Error(
|
|
2053
|
+
`Failed to parse JSON from LLM response: ${response.slice(0, 200)}`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// ../core/src/llm/prompts.ts
|
|
2059
|
+
function buildEnhancePrompt(graph) {
|
|
2060
|
+
const servicesInfo = graph.nodes.map((node) => formatServiceInfo(node)).join("\n\n");
|
|
2061
|
+
const edgesInfo = graph.edges.map((edge) => `- ${edge.from} -> ${edge.to} (${edge.type})`).join("\n");
|
|
2062
|
+
return `You are an infrastructure architect analyzing a system to create clear, informative architecture diagrams.
|
|
2063
|
+
|
|
2064
|
+
## Task
|
|
2065
|
+
Analyze the following infrastructure services and enhance each with:
|
|
2066
|
+
1. A brief description (what it does)
|
|
2067
|
+
2. A logical group name (for visual grouping)
|
|
2068
|
+
3. For services that interact with message queues: their queue role (producer, consumer, or both)
|
|
2069
|
+
|
|
2070
|
+
## Services to Analyze
|
|
2071
|
+
|
|
2072
|
+
${servicesInfo}
|
|
2073
|
+
|
|
2074
|
+
## Dependencies
|
|
2075
|
+
${edgesInfo || "No explicit dependencies defined"}
|
|
2076
|
+
|
|
2077
|
+
## Response Format
|
|
2078
|
+
Return ONLY valid JSON matching this structure:
|
|
2079
|
+
\`\`\`json
|
|
2080
|
+
{
|
|
2081
|
+
"services": [
|
|
2082
|
+
{
|
|
2083
|
+
"id": "service-id",
|
|
2084
|
+
"description": "Brief description of what this service does",
|
|
2085
|
+
"group": "Group Name",
|
|
2086
|
+
"queueRole": "producer"
|
|
2087
|
+
}
|
|
2088
|
+
],
|
|
2089
|
+
"groups": [
|
|
2090
|
+
{
|
|
2091
|
+
"name": "Group Name",
|
|
2092
|
+
"description": "Brief description of this logical group"
|
|
2093
|
+
}
|
|
2094
|
+
]
|
|
2095
|
+
}
|
|
2096
|
+
\`\`\`
|
|
2097
|
+
|
|
2098
|
+
## Guidelines
|
|
2099
|
+
- Keep descriptions under 50 words
|
|
2100
|
+
- Group related services together (e.g., all databases in "Data Stores")
|
|
2101
|
+
- Use clear, non-technical group names suitable for architecture diagrams
|
|
2102
|
+
- Infer purpose from service name, image, ports, and environment variables
|
|
2103
|
+
- Check every service name for role hints (e.g., web/ui for frontends, schema/migrations for data tasks)
|
|
2104
|
+
- Create 3-5 groups for typical infrastructure (avoid too many or too few)
|
|
2105
|
+
- Only include queueRole for services that produce to or consume from message queues (Kafka, RabbitMQ, etc.)`;
|
|
2106
|
+
}
|
|
2107
|
+
function formatServiceInfo(node) {
|
|
2108
|
+
const lines = [`### ${node.name} (id: ${node.id})`];
|
|
2109
|
+
if (node.image) {
|
|
2110
|
+
lines.push(`- Image: ${node.image}`);
|
|
2111
|
+
}
|
|
2112
|
+
lines.push(`- Type: ${node.type}`);
|
|
2113
|
+
if (node.ports?.length) {
|
|
2114
|
+
const ports = node.ports.map((p) => `${p.external ?? "?"}:${p.internal}`).join(", ");
|
|
2115
|
+
lines.push(`- Ports: ${ports}`);
|
|
2116
|
+
}
|
|
2117
|
+
if (node.environment && Object.keys(node.environment).length > 0) {
|
|
2118
|
+
const envKeys = Object.keys(node.environment).slice(0, 10).join(", ");
|
|
2119
|
+
lines.push(
|
|
2120
|
+
`- Environment: ${envKeys}${Object.keys(node.environment).length > 10 ? "..." : ""}`
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
if (node.volumes?.length) {
|
|
2124
|
+
const volumes = node.volumes.slice(0, 3).map((v) => `${v.source}:${v.target}`).join(", ");
|
|
2125
|
+
lines.push(`- Volumes: ${volumes}${node.volumes.length > 3 ? "..." : ""}`);
|
|
2126
|
+
}
|
|
2127
|
+
return lines.join("\n");
|
|
2128
|
+
}
|
|
2129
|
+
function applyEnhancements(graph, enhancements) {
|
|
2130
|
+
const enhancementMap = new Map(enhancements.services.map((s) => [s.id, s]));
|
|
2131
|
+
const enhancedNodes = graph.nodes.map((node) => {
|
|
2132
|
+
const enhancement = enhancementMap.get(node.id);
|
|
2133
|
+
if (enhancement) {
|
|
2134
|
+
return {
|
|
2135
|
+
...node,
|
|
2136
|
+
description: enhancement.description,
|
|
2137
|
+
group: enhancement.group,
|
|
2138
|
+
queueRole: enhancement.queueRole
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
return node;
|
|
2142
|
+
});
|
|
2143
|
+
return {
|
|
2144
|
+
...graph,
|
|
2145
|
+
nodes: enhancedNodes
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ../core/src/output/png.ts
|
|
2150
|
+
async function checkBrowserAvailability() {
|
|
2151
|
+
try {
|
|
2152
|
+
const puppeteer = await import("puppeteer");
|
|
2153
|
+
const browser = await puppeteer.default.launch({
|
|
2154
|
+
headless: true,
|
|
2155
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
2156
|
+
});
|
|
2157
|
+
const executablePath = browser.process()?.spawnfile;
|
|
2158
|
+
await browser.close();
|
|
2159
|
+
return {
|
|
2160
|
+
available: true,
|
|
2161
|
+
browserPath: executablePath
|
|
2162
|
+
};
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2165
|
+
if (error.includes("Could not find Chromium") || error.includes("ENOENT")) {
|
|
2166
|
+
return {
|
|
2167
|
+
available: false,
|
|
2168
|
+
error: `Chromium not found. Puppeteer should auto-download it on first run.
|
|
2169
|
+
|
|
2170
|
+
If download failed, try:
|
|
2171
|
+
1. Run: npx puppeteer browsers install chrome
|
|
2172
|
+
2. Or set PUPPETEER_EXECUTABLE_PATH to your Chrome/Chromium installation
|
|
2173
|
+
|
|
2174
|
+
On macOS: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
|
|
2175
|
+
On Linux: /usr/bin/chromium-browser or /usr/bin/google-chrome`
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
if (error.includes("EACCES") || error.includes("permission")) {
|
|
2179
|
+
return {
|
|
2180
|
+
available: false,
|
|
2181
|
+
error: `Permission error launching browser.
|
|
2182
|
+
|
|
2183
|
+
Try running with appropriate permissions or set PUPPETEER_EXECUTABLE_PATH
|
|
2184
|
+
to a browser you have access to.`
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
available: false,
|
|
2189
|
+
error: `Failed to launch browser: ${error}`
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
var DEFAULT_OPTIONS = {
|
|
2194
|
+
width: 1920,
|
|
2195
|
+
height: 1080,
|
|
2196
|
+
backgroundColor: "#ffffff",
|
|
2197
|
+
scale: 2,
|
|
2198
|
+
padding: 50
|
|
2199
|
+
};
|
|
2200
|
+
async function renderExcalidrawToPng(excalidraw, options) {
|
|
2201
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
2202
|
+
const puppeteer = await import("puppeteer");
|
|
2203
|
+
const browser = await puppeteer.default.launch({
|
|
2204
|
+
headless: true,
|
|
2205
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
2206
|
+
});
|
|
2207
|
+
try {
|
|
2208
|
+
const page = await browser.newPage();
|
|
2209
|
+
await page.setViewport({
|
|
2210
|
+
width: 1920,
|
|
2211
|
+
height: 1080,
|
|
2212
|
+
deviceScaleFactor: opts.scale
|
|
2213
|
+
});
|
|
2214
|
+
const html = createExcalidrawExportHtml(excalidraw, opts);
|
|
2215
|
+
await page.setContent(html, { waitUntil: "networkidle2", timeout: 6e4 });
|
|
2216
|
+
await page.waitForFunction(
|
|
2217
|
+
() => window.__EXCALIDRAW_LOADED__,
|
|
2218
|
+
{ timeout: 6e4 }
|
|
2219
|
+
);
|
|
2220
|
+
await page.evaluate(() => {
|
|
2221
|
+
;
|
|
2222
|
+
window.exportToPng();
|
|
2223
|
+
});
|
|
2224
|
+
await page.waitForFunction(
|
|
2225
|
+
() => window.__EXPORT_COMPLETE__,
|
|
2226
|
+
{ timeout: 6e4 }
|
|
2227
|
+
);
|
|
2228
|
+
const result = await page.evaluate(() => {
|
|
2229
|
+
return {
|
|
2230
|
+
data: window.__EXPORT_DATA__,
|
|
2231
|
+
error: window.__EXPORT_ERROR__
|
|
2232
|
+
};
|
|
2233
|
+
});
|
|
2234
|
+
if (!result.data) {
|
|
2235
|
+
const errorMsg = result.error || "Unknown error";
|
|
2236
|
+
throw new Error(`Failed to export PNG from Excalidraw: ${errorMsg}`);
|
|
2237
|
+
}
|
|
2238
|
+
return Buffer.from(result.data, "base64");
|
|
2239
|
+
} finally {
|
|
2240
|
+
await browser.close();
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
function createExcalidrawExportHtml(excalidraw, options) {
|
|
2244
|
+
const data = JSON.stringify(excalidraw);
|
|
2245
|
+
return `<!DOCTYPE html>
|
|
2246
|
+
<html>
|
|
2247
|
+
<head>
|
|
2248
|
+
<meta charset="utf-8">
|
|
2249
|
+
<script type="importmap">
|
|
2250
|
+
{
|
|
2251
|
+
"imports": {
|
|
2252
|
+
"react": "https://esm.sh/react@18.2.0",
|
|
2253
|
+
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
|
|
2254
|
+
"react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime",
|
|
2255
|
+
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
2256
|
+
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client"
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
</script>
|
|
2260
|
+
<style>
|
|
2261
|
+
* { margin: 0; padding: 0; }
|
|
2262
|
+
body { background: ${options.backgroundColor}; }
|
|
2263
|
+
#root { width: 100vw; height: 100vh; }
|
|
2264
|
+
</style>
|
|
2265
|
+
</head>
|
|
2266
|
+
<body>
|
|
2267
|
+
<div id="root"></div>
|
|
2268
|
+
|
|
2269
|
+
<script type="module">
|
|
2270
|
+
import React from 'react';
|
|
2271
|
+
import { createRoot } from 'react-dom/client';
|
|
2272
|
+
import { Excalidraw, exportToBlob } from 'https://esm.sh/@excalidraw/excalidraw@0.18.0?external=react,react-dom';
|
|
2273
|
+
|
|
2274
|
+
window.__EXPORT_COMPLETE__ = false;
|
|
2275
|
+
window.__EXPORT_DATA__ = null;
|
|
2276
|
+
window.__EXPORT_ERROR__ = null;
|
|
2277
|
+
window.__EXCALIDRAW_LOADED__ = false;
|
|
2278
|
+
|
|
2279
|
+
const data = ${data};
|
|
2280
|
+
|
|
2281
|
+
// Reference to the Excalidraw API
|
|
2282
|
+
let excalidrawAPI = null;
|
|
2283
|
+
|
|
2284
|
+
// Create the Excalidraw component
|
|
2285
|
+
const App = () => {
|
|
2286
|
+
return React.createElement(Excalidraw, {
|
|
2287
|
+
excalidrawAPI: (api) => {
|
|
2288
|
+
excalidrawAPI = api;
|
|
2289
|
+
window.__EXCALIDRAW_LOADED__ = true;
|
|
2290
|
+
},
|
|
2291
|
+
initialData: {
|
|
2292
|
+
elements: data.elements || [],
|
|
2293
|
+
appState: {
|
|
2294
|
+
viewBackgroundColor: "${options.backgroundColor}",
|
|
2295
|
+
},
|
|
2296
|
+
files: data.files || {},
|
|
2297
|
+
},
|
|
2298
|
+
UIOptions: {
|
|
2299
|
+
canvasActions: {
|
|
2300
|
+
export: false,
|
|
2301
|
+
loadScene: false,
|
|
2302
|
+
saveToActiveFile: false,
|
|
2303
|
+
},
|
|
2304
|
+
},
|
|
2305
|
+
});
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
// Render the app
|
|
2309
|
+
const root = createRoot(document.getElementById('root'));
|
|
2310
|
+
root.render(React.createElement(App));
|
|
2311
|
+
|
|
2312
|
+
window.exportToPng = async function() {
|
|
2313
|
+
try {
|
|
2314
|
+
if (!excalidrawAPI) {
|
|
2315
|
+
window.__EXPORT_ERROR__ = 'Excalidraw API not ready';
|
|
2316
|
+
window.__EXPORT_COMPLETE__ = true;
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Get the processed elements from Excalidraw (with elbow routing applied)
|
|
2321
|
+
const elements = excalidrawAPI.getSceneElements();
|
|
2322
|
+
const appState = excalidrawAPI.getAppState();
|
|
2323
|
+
const files = excalidrawAPI.getFiles();
|
|
2324
|
+
|
|
2325
|
+
const blob = await exportToBlob({
|
|
2326
|
+
elements,
|
|
2327
|
+
appState: {
|
|
2328
|
+
...appState,
|
|
2329
|
+
exportBackground: true,
|
|
2330
|
+
viewBackgroundColor: "${options.backgroundColor}",
|
|
2331
|
+
},
|
|
2332
|
+
files,
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
const reader = new FileReader();
|
|
2336
|
+
reader.onloadend = () => {
|
|
2337
|
+
window.__EXPORT_DATA__ = reader.result.split(',')[1];
|
|
2338
|
+
window.__EXPORT_COMPLETE__ = true;
|
|
2339
|
+
};
|
|
2340
|
+
reader.onerror = (err) => {
|
|
2341
|
+
window.__EXPORT_ERROR__ = 'FileReader error: ' + err;
|
|
2342
|
+
window.__EXPORT_COMPLETE__ = true;
|
|
2343
|
+
};
|
|
2344
|
+
reader.readAsDataURL(blob);
|
|
2345
|
+
} catch (error) {
|
|
2346
|
+
window.__EXPORT_ERROR__ = 'Export failed: ' + error.message;
|
|
2347
|
+
window.__EXPORT_COMPLETE__ = true;
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
</script>
|
|
2351
|
+
</body>
|
|
2352
|
+
</html>`;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// ../core/src/pipeline/storage.ts
|
|
2356
|
+
import { mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
2357
|
+
import { join as join3 } from "path";
|
|
2358
|
+
|
|
2359
|
+
// ../core/src/config/index.ts
|
|
2360
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
2361
|
+
import { homedir } from "os";
|
|
2362
|
+
import { dirname, join as join5 } from "path";
|
|
2363
|
+
function getConfigPath() {
|
|
2364
|
+
return join5(homedir(), ".config", "clarity", "config.json");
|
|
2365
|
+
}
|
|
2366
|
+
function loadConfig() {
|
|
2367
|
+
const configPath = getConfigPath();
|
|
2368
|
+
if (!existsSync2(configPath)) {
|
|
2369
|
+
return {};
|
|
2370
|
+
}
|
|
2371
|
+
try {
|
|
2372
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
2373
|
+
return JSON.parse(content);
|
|
2374
|
+
} catch {
|
|
2375
|
+
return {};
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
function saveConfig(config) {
|
|
2379
|
+
const configPath = getConfigPath();
|
|
2380
|
+
const configDir = dirname(configPath);
|
|
2381
|
+
if (!existsSync2(configDir)) {
|
|
2382
|
+
mkdirSync(configDir, { recursive: true });
|
|
2383
|
+
}
|
|
2384
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
2385
|
+
}
|
|
2386
|
+
function getApiKey() {
|
|
2387
|
+
const config = loadConfig();
|
|
2388
|
+
return config.openRouterApiKey ?? process.env.OPENROUTER_API_KEY;
|
|
2389
|
+
}
|
|
2390
|
+
function maskApiKey(key) {
|
|
2391
|
+
if (key.length <= 12) {
|
|
2392
|
+
return "*".repeat(key.length);
|
|
2393
|
+
}
|
|
2394
|
+
return `${key.slice(0, 8)}...${key.slice(-4)}`;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/commands/config.ts
|
|
2398
|
+
import { Command } from "commander";
|
|
2399
|
+
var configCommand = new Command("config").description(
|
|
2400
|
+
"Manage Clarity configuration"
|
|
2401
|
+
);
|
|
2402
|
+
configCommand.command("set-key <key>").description("Set your OpenRouter API key").action((key) => {
|
|
2403
|
+
const config = loadConfig();
|
|
2404
|
+
config.openRouterApiKey = key;
|
|
2405
|
+
saveConfig(config);
|
|
2406
|
+
console.log(`API key saved to ${getConfigPath()}`);
|
|
2407
|
+
});
|
|
2408
|
+
configCommand.command("show").description("Show current configuration").action(() => {
|
|
2409
|
+
const configPath = getConfigPath();
|
|
2410
|
+
const config = loadConfig();
|
|
2411
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
2412
|
+
console.log(`Config file: ${configPath}
|
|
2413
|
+
`);
|
|
2414
|
+
if (config.openRouterApiKey) {
|
|
2415
|
+
console.log(
|
|
2416
|
+
`OpenRouter API key (config): ${maskApiKey(config.openRouterApiKey)}`
|
|
2417
|
+
);
|
|
2418
|
+
} else if (envKey) {
|
|
2419
|
+
console.log(`OpenRouter API key (env): ${maskApiKey(envKey)}`);
|
|
2420
|
+
} else {
|
|
2421
|
+
console.log("OpenRouter API key: not set");
|
|
2422
|
+
console.log("\nTo enable LLM enhancement, run:");
|
|
2423
|
+
console.log(" clarity config set-key <your-openrouter-api-key>");
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
configCommand.command("clear").description("Clear the stored API key").action(() => {
|
|
2427
|
+
const config = loadConfig();
|
|
2428
|
+
delete config.openRouterApiKey;
|
|
2429
|
+
saveConfig(config);
|
|
2430
|
+
console.log("API key cleared");
|
|
2431
|
+
if (process.env.OPENROUTER_API_KEY) {
|
|
2432
|
+
console.log(
|
|
2433
|
+
"\nNote: OPENROUTER_API_KEY environment variable is still set"
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
configCommand.command("path").description("Show the config file path").action(() => {
|
|
2438
|
+
console.log(getConfigPath());
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
// src/generate.ts
|
|
2442
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
2443
|
+
import { basename, join as join6, resolve } from "path";
|
|
2444
|
+
async function detectIaCFiles(dirPath) {
|
|
2445
|
+
const files = [];
|
|
2446
|
+
const entries = await readdir2(dirPath, { withFileTypes: true });
|
|
2447
|
+
for (const entry of entries) {
|
|
2448
|
+
const fullPath = join6(dirPath, entry.name);
|
|
2449
|
+
if (entry.isFile()) {
|
|
2450
|
+
const name = entry.name.toLowerCase();
|
|
2451
|
+
if (name.includes("docker-compose") || name === "compose.yml" || name === "compose.yaml") {
|
|
2452
|
+
files.push({ type: "docker-compose", path: fullPath, name: entry.name });
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (entry.isDirectory()) {
|
|
2456
|
+
const chartPath = join6(fullPath, "Chart.yaml");
|
|
2457
|
+
try {
|
|
2458
|
+
await stat(chartPath);
|
|
2459
|
+
files.push({ type: "helm", path: fullPath, name: entry.name });
|
|
2460
|
+
} catch {
|
|
2461
|
+
try {
|
|
2462
|
+
await stat(join6(fullPath, "Chart.yml"));
|
|
2463
|
+
files.push({ type: "helm", path: fullPath, name: entry.name });
|
|
2464
|
+
} catch {
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
try {
|
|
2470
|
+
await stat(join6(dirPath, "Chart.yaml"));
|
|
2471
|
+
files.push({ type: "helm", path: dirPath, name: basename(dirPath) });
|
|
2472
|
+
} catch {
|
|
2473
|
+
try {
|
|
2474
|
+
await stat(join6(dirPath, "Chart.yml"));
|
|
2475
|
+
files.push({ type: "helm", path: dirPath, name: basename(dirPath) });
|
|
2476
|
+
} catch {
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return files;
|
|
2480
|
+
}
|
|
2481
|
+
async function parseIaC(detected, verbose) {
|
|
2482
|
+
if (detected.type === "docker-compose") {
|
|
2483
|
+
if (verbose) console.log(` Parsing Docker Compose: ${detected.path}`);
|
|
2484
|
+
const content = await readFile2(detected.path, "utf-8");
|
|
2485
|
+
return parseDockerCompose(content, detected.name, detected.name);
|
|
2486
|
+
}
|
|
2487
|
+
if (detected.type === "helm") {
|
|
2488
|
+
if (verbose) console.log(` Parsing Helm chart: ${detected.path}`);
|
|
2489
|
+
return parseHelmChart(detected.path, detected.name, detected.path);
|
|
2490
|
+
}
|
|
2491
|
+
throw new Error(`Unknown IaC type: ${detected.type}`);
|
|
2492
|
+
}
|
|
2493
|
+
async function enhanceGraph(graph, apiKey, verbose) {
|
|
2494
|
+
if (verbose) console.log(" Enhancing with LLM...");
|
|
2495
|
+
const prompt = buildEnhancePrompt(graph);
|
|
2496
|
+
const response = await sendMessage(apiKey, prompt);
|
|
2497
|
+
const enhancements = parseJsonResponse(response);
|
|
2498
|
+
if (!enhancements?.services) {
|
|
2499
|
+
if (verbose) console.log(" Warning: No enhancements returned from LLM");
|
|
2500
|
+
return graph;
|
|
2501
|
+
}
|
|
2502
|
+
return applyEnhancements(graph, enhancements);
|
|
2503
|
+
}
|
|
2504
|
+
async function generate(inputPath, options = {}) {
|
|
2505
|
+
const resolvedInput = resolve(inputPath);
|
|
2506
|
+
const outputDir = resolve(options.output ?? "./docs/diagrams");
|
|
2507
|
+
const verbose = options.verbose ?? false;
|
|
2508
|
+
const skipPng = options.png === false;
|
|
2509
|
+
const skipLlm = options.llm === false;
|
|
2510
|
+
if (!skipPng) {
|
|
2511
|
+
const browserCheck = await checkBrowserAvailability();
|
|
2512
|
+
if (!browserCheck.available) {
|
|
2513
|
+
console.error(
|
|
2514
|
+
"\x1B[31m\u2717\x1B[0m Browser not available for PNG rendering\n"
|
|
2515
|
+
);
|
|
2516
|
+
console.error(browserCheck.error);
|
|
2517
|
+
console.error("\nUse --no-png to skip PNG generation");
|
|
2518
|
+
process.exit(1);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
const inputStat = await stat(resolvedInput);
|
|
2522
|
+
let detected;
|
|
2523
|
+
if (inputStat.isFile()) {
|
|
2524
|
+
const name = basename(resolvedInput).toLowerCase();
|
|
2525
|
+
if (name.includes("docker-compose") || name === "compose.yml" || name === "compose.yaml") {
|
|
2526
|
+
detected = [
|
|
2527
|
+
{
|
|
2528
|
+
type: "docker-compose",
|
|
2529
|
+
path: resolvedInput,
|
|
2530
|
+
name: basename(resolvedInput)
|
|
2531
|
+
}
|
|
2532
|
+
];
|
|
2533
|
+
} else {
|
|
2534
|
+
console.error(`\x1B[31m\u2717\x1B[0m Unrecognized file type: ${resolvedInput}`);
|
|
2535
|
+
console.error("Supported: docker-compose.yml, compose.yml");
|
|
2536
|
+
process.exit(1);
|
|
2537
|
+
}
|
|
2538
|
+
} else if (inputStat.isDirectory()) {
|
|
2539
|
+
detected = await detectIaCFiles(resolvedInput);
|
|
2540
|
+
if (detected.length === 0) {
|
|
2541
|
+
console.error(`\x1B[31m\u2717\x1B[0m No IaC files found in: ${resolvedInput}`);
|
|
2542
|
+
console.error("Looking for: docker-compose.yml, compose.yml, Chart.yaml");
|
|
2543
|
+
process.exit(1);
|
|
2544
|
+
}
|
|
2545
|
+
} else {
|
|
2546
|
+
console.error(`\x1B[31m\u2717\x1B[0m Invalid path: ${resolvedInput}`);
|
|
2547
|
+
process.exit(1);
|
|
2548
|
+
}
|
|
2549
|
+
await mkdir2(outputDir, { recursive: true });
|
|
2550
|
+
const apiKey = getApiKey();
|
|
2551
|
+
const llmEnabled = !skipLlm && !!apiKey;
|
|
2552
|
+
if (!skipLlm && !apiKey && verbose) {
|
|
2553
|
+
console.log(" Note: No API key configured, skipping LLM enhancement");
|
|
2554
|
+
console.log(" Run: iac-diagrams config set-key <your-openrouter-key>");
|
|
2555
|
+
}
|
|
2556
|
+
for (const file of detected) {
|
|
2557
|
+
console.log(`
|
|
2558
|
+
Generating diagram for: ${file.name}`);
|
|
2559
|
+
if (verbose) console.log(" Step 1: Parsing...");
|
|
2560
|
+
let graph = await parseIaC(file, verbose);
|
|
2561
|
+
if (verbose) {
|
|
2562
|
+
console.log(` Found ${graph.nodes.length} services`);
|
|
2563
|
+
console.log(` Found ${graph.edges.length} dependencies`);
|
|
2564
|
+
}
|
|
2565
|
+
if (llmEnabled && apiKey) {
|
|
2566
|
+
if (verbose) console.log(" Step 2: Enhancing with LLM...");
|
|
2567
|
+
try {
|
|
2568
|
+
graph = await enhanceGraph(graph, apiKey, verbose);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
console.error(
|
|
2571
|
+
` \x1B[33m!\x1B[0m LLM enhancement failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
} else if (verbose) {
|
|
2575
|
+
console.log(" Step 2: Skipping LLM enhancement");
|
|
2576
|
+
}
|
|
2577
|
+
if (verbose) console.log(" Step 3: Computing layout...");
|
|
2578
|
+
const elkConversion = infraGraphToElk(graph);
|
|
2579
|
+
const elkLayoutResult = await runLayout(elkConversion.graph);
|
|
2580
|
+
if (verbose) console.log(" Step 4: Generating Excalidraw...");
|
|
2581
|
+
const excalidraw = renderWithElkLayout(graph, elkLayoutResult.graph);
|
|
2582
|
+
const baseName = file.name.replace(/\.(yml|yaml)$/i, "");
|
|
2583
|
+
const excalidrawPath = join6(outputDir, `${baseName}.excalidraw`);
|
|
2584
|
+
await writeFile2(excalidrawPath, JSON.stringify(excalidraw, null, 2));
|
|
2585
|
+
console.log(` \x1B[32m\u2713\x1B[0m Saved: ${excalidrawPath}`);
|
|
2586
|
+
if (!skipPng) {
|
|
2587
|
+
if (verbose) console.log(" Step 5: Rendering PNG...");
|
|
2588
|
+
const pngBuffer = await renderExcalidrawToPng(excalidraw);
|
|
2589
|
+
const pngPath = join6(outputDir, `${baseName}.png`);
|
|
2590
|
+
await writeFile2(pngPath, pngBuffer);
|
|
2591
|
+
console.log(` \x1B[32m\u2713\x1B[0m Saved: ${pngPath}`);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
console.log("\n\x1B[32m\u2713\x1B[0m Done!");
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// src/index.ts
|
|
2598
|
+
var program = new Command2().name("iac-diagrams").description(
|
|
2599
|
+
"Generate architecture diagrams from Infrastructure-as-Code files"
|
|
2600
|
+
).version("0.1.0").argument(
|
|
2601
|
+
"[path]",
|
|
2602
|
+
"File or directory to process (default: current directory)",
|
|
2603
|
+
"."
|
|
2604
|
+
).option("-o, --output <dir>", "Output directory", "./docs/diagrams").option("--no-llm", "Disable LLM enhancement").option("--no-png", "Skip PNG rendering (output .excalidraw only)").option("-v, --verbose", "Show detailed output").action(
|
|
2605
|
+
async (path, options) => {
|
|
2606
|
+
await generate(path, options);
|
|
2607
|
+
}
|
|
2608
|
+
);
|
|
2609
|
+
program.addCommand(configCommand);
|
|
2610
|
+
program.parse();
|