ghcr-manager-visualizer 0.0.1-dev.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.
@@ -0,0 +1,839 @@
1
+ import cytoscape from "/vendor/cytoscape.js";
2
+
3
+ const elements = {
4
+ form: document.querySelector("#search-form"),
5
+ owner: document.querySelector("#owner"),
6
+ packageName: document.querySelector("#package"),
7
+ scanId: document.querySelector("#scan-id"),
8
+ compareScanId: document.querySelector("#compare-scan-id"),
9
+ lookupMode: document.querySelector("#lookup-mode"),
10
+ lookupValue: document.querySelector("#lookup-value"),
11
+ lookupSuggestions: document.querySelector("#lookup-suggestions"),
12
+ depth: document.querySelector("#depth"),
13
+ status: document.querySelector("#status"),
14
+ detailsEmpty: document.querySelector("#details-empty"),
15
+ details: document.querySelector("#details"),
16
+ expandNode: document.querySelector("#expand-node"),
17
+ centerNode: document.querySelector("#center-node"),
18
+ showRawJson: document.querySelector("#show-raw-json"),
19
+ rawJsonDialog: document.querySelector("#raw-json-dialog"),
20
+ closeRawJson: document.querySelector("#close-raw-json"),
21
+ rawJsonContent: document.querySelector("#raw-json-content"),
22
+ detailDigest: document.querySelector("#detail-digest"),
23
+ detailVersion: document.querySelector("#detail-version"),
24
+ detailCreatedAt: document.querySelector("#detail-created-at"),
25
+ detailUpdatedAt: document.querySelector("#detail-updated-at"),
26
+ detailKind: document.querySelector("#detail-kind"),
27
+ detailMediaType: document.querySelector("#detail-media-type"),
28
+ detailPlatform: document.querySelector("#detail-platform"),
29
+ detailArtifactType: document.querySelector("#detail-artifact-type"),
30
+ detailSubject: document.querySelector("#detail-subject"),
31
+ detailTags: document.querySelector("#detail-tags"),
32
+ zoomIn: document.querySelector("#zoom-in"),
33
+ zoomOut: document.querySelector("#zoom-out"),
34
+ zoomFit: document.querySelector("#zoom-fit")
35
+ };
36
+
37
+ const state = {
38
+ currentGraph: null,
39
+ graphContext: null,
40
+ positionsByDigest: new Map(),
41
+ positionsByViewKey: new Map(),
42
+ selectedDigest: null,
43
+ selectedManifestDetails: null,
44
+ lookupSuggestionRequestId: 0
45
+ };
46
+
47
+ const _ZOOM_STEP = 1.15;
48
+ const _GRAPH_PADDING = 30;
49
+ const _WHEEL_SENSITIVITY = 0.18;
50
+
51
+ const cy = cytoscape({
52
+ container: document.querySelector("#graph"),
53
+ wheelSensitivity: _WHEEL_SENSITIVITY,
54
+ style: [
55
+ {
56
+ selector: "node",
57
+ style: {
58
+ "background-color": "data(nodeColor)",
59
+ shape: "round-rectangle",
60
+ label: "data(label)",
61
+ color: "#102017",
62
+ "font-size": 11,
63
+ "font-weight": 600,
64
+ "text-wrap": "wrap",
65
+ "text-max-width": 140,
66
+ "text-valign": "center",
67
+ "text-halign": "center",
68
+ "background-opacity": 0.22,
69
+ "border-width": 3,
70
+ "border-color": "data(borderColor)",
71
+ width: 156,
72
+ height: 84,
73
+ padding: 12
74
+ }
75
+ },
76
+ {
77
+ selector: "node.center",
78
+ style: {
79
+ "border-width": 4,
80
+ "overlay-color": "#8d4d10",
81
+ "overlay-opacity": 0.08,
82
+ "overlay-padding": 8
83
+ }
84
+ },
85
+ {
86
+ selector: "node.selected",
87
+ style: {
88
+ "border-width": 4,
89
+ "overlay-color": "#165a86",
90
+ "overlay-opacity": 0.12,
91
+ "overlay-padding": 8
92
+ }
93
+ },
94
+ {
95
+ selector: "node.removed",
96
+ style: {
97
+ opacity: 0.72
98
+ }
99
+ },
100
+ {
101
+ selector: "edge",
102
+ style: {
103
+ width: 3,
104
+ "curve-style": "bezier",
105
+ "line-color": "#41594c",
106
+ "target-arrow-color": "#41594c",
107
+ "target-arrow-shape": "triangle",
108
+ label: "data(kind)",
109
+ "font-size": 9,
110
+ "text-rotation": "autorotate",
111
+ "text-background-color": "#edf2f0",
112
+ "text-background-opacity": 1,
113
+ "text-background-padding": 2
114
+ }
115
+ },
116
+ {
117
+ selector: 'edge[kind = "referrer"]',
118
+ style: {
119
+ "line-style": "dashed"
120
+ }
121
+ },
122
+ {
123
+ selector: 'edge[kind = "digest-tag-referrer"]',
124
+ style: {
125
+ "line-style": "dotted"
126
+ }
127
+ }
128
+ ]
129
+ });
130
+
131
+ elements.form.addEventListener("submit", async (event) => {
132
+ event.preventDefault();
133
+ await loadGraphFromForm();
134
+ });
135
+
136
+ elements.owner.addEventListener("change", async () => {
137
+ await handleOwnerChange();
138
+ });
139
+
140
+ elements.packageName.addEventListener("change", async () => {
141
+ await handlePackageChange();
142
+ });
143
+
144
+ elements.scanId.addEventListener("change", () => {
145
+ clearLookupSuggestions();
146
+ });
147
+
148
+ elements.lookupMode.addEventListener("change", () => {
149
+ clearLookupSuggestions();
150
+ });
151
+
152
+ elements.lookupValue.addEventListener("input", async () => {
153
+ await updateLookupSuggestions();
154
+ });
155
+
156
+ elements.expandNode.addEventListener("click", async () => {
157
+ await expandSelectedNode();
158
+ });
159
+
160
+ elements.centerNode.addEventListener("click", async () => {
161
+ await centerSelectedNode();
162
+ });
163
+
164
+ elements.showRawJson.addEventListener("click", () => {
165
+ if (!state.selectedManifestDetails?.rawJson) {
166
+ return;
167
+ }
168
+
169
+ elements.rawJsonContent.textContent = JSON.stringify(JSON.parse(state.selectedManifestDetails.rawJson), null, 2);
170
+ elements.rawJsonDialog.showModal();
171
+ });
172
+
173
+ elements.closeRawJson.addEventListener("click", () => {
174
+ elements.rawJsonDialog.close();
175
+ });
176
+
177
+ elements.zoomIn.addEventListener("click", () => {
178
+ zoomBy(_ZOOM_STEP);
179
+ });
180
+
181
+ elements.zoomOut.addEventListener("click", () => {
182
+ zoomBy(1 / _ZOOM_STEP);
183
+ });
184
+
185
+ elements.zoomFit.addEventListener("click", () => {
186
+ cy.fit(undefined, _GRAPH_PADDING);
187
+ });
188
+
189
+ cy.on("tap", "node", async (event) => {
190
+ await selectNode(event.target.id());
191
+ });
192
+
193
+ await initializeSelectors();
194
+
195
+ async function loadGraphFromForm() {
196
+ persistCurrentLayoutState();
197
+ setStatus("Resolving manifest...");
198
+ const resolved = await fetchJson(resolveUrl());
199
+ await loadGraph(resolved.digest);
200
+ }
201
+
202
+ async function loadGraph(centerDigest) {
203
+ persistCurrentLayoutState();
204
+ const url = packageBaseUrl("/graph");
205
+ url.searchParams.set("center_digest", centerDigest);
206
+ url.searchParams.set("depth", elements.depth.value);
207
+ appendOptionalScanParams(url);
208
+ setStatus("Loading graph...");
209
+ const graph = await fetchJson(url);
210
+ state.currentGraph = graph;
211
+ renderGraph(graph, "replace");
212
+ setStatus(`Loaded ${graph.nodes.length} manifests and ${graph.edges.length} edges.`);
213
+ await selectNode(centerDigest);
214
+ }
215
+
216
+ async function expandSelectedNode() {
217
+ if (!state.currentGraph || !state.selectedDigest) {
218
+ return;
219
+ }
220
+
221
+ persistCurrentLayoutState();
222
+ const url = packageBaseUrl("/graph");
223
+ url.searchParams.set("center_digest", state.selectedDigest);
224
+ url.searchParams.set("depth", "1");
225
+ appendOptionalScanParams(url);
226
+ setStatus(`Expanding ${shortDigest(state.selectedDigest)}...`);
227
+ const expansionGraph = await fetchJson(url);
228
+ const previousNodeCount = state.currentGraph.nodes.length;
229
+ const mergedGraph = mergeGraphs(state.currentGraph, expansionGraph);
230
+ state.currentGraph = mergedGraph;
231
+ renderGraph(mergedGraph, "expand", {
232
+ expansionSourceDigest: state.selectedDigest
233
+ });
234
+ const addedNodeCount = mergedGraph.nodes.length - previousNodeCount;
235
+ setStatus(
236
+ addedNodeCount > 0
237
+ ? `Expanded ${shortDigest(state.selectedDigest)} by ${addedNodeCount} manifests.`
238
+ : `No new manifests found from ${shortDigest(state.selectedDigest)}.`
239
+ );
240
+ await selectNode(state.selectedDigest);
241
+ }
242
+
243
+ async function centerSelectedNode() {
244
+ if (!state.selectedDigest) {
245
+ return;
246
+ }
247
+
248
+ await loadGraph(state.selectedDigest);
249
+ }
250
+
251
+ async function selectNode(digest) {
252
+ state.selectedDigest = digest;
253
+ syncSelectedNodeClass();
254
+ await loadManifestDetails(digest);
255
+ }
256
+
257
+ async function loadManifestDetails(digest) {
258
+ const url = packageBaseUrl(`/manifests/${encodeURIComponent(digest)}`);
259
+ appendOptionalScanParams(url);
260
+ const details = await fetchJson(url);
261
+ state.selectedManifestDetails = details;
262
+ elements.details.hidden = false;
263
+ elements.detailsEmpty.hidden = true;
264
+ elements.detailDigest.textContent = details.digest;
265
+ elements.detailVersion.textContent = String(details.versionId);
266
+ elements.detailCreatedAt.textContent = details.createdAt;
267
+ elements.detailUpdatedAt.textContent = details.updatedAt;
268
+ elements.detailKind.textContent = details.manifestKind ?? "-";
269
+ elements.detailMediaType.textContent = details.mediaType;
270
+ elements.detailPlatform.textContent = details.displayPlatform ?? "-";
271
+ elements.detailArtifactType.textContent = details.artifactType ?? "-";
272
+ elements.detailSubject.textContent = details.subjectDigest ?? "-";
273
+ renderTagList(elements.detailTags, details.tags);
274
+ elements.expandNode.disabled = false;
275
+ elements.centerNode.disabled = state.currentGraph?.centerDigest === digest;
276
+ elements.showRawJson.disabled = !details.rawJson;
277
+ }
278
+
279
+ function renderGraph(graph, mode, options = {}) {
280
+ const viewKey = buildGraphViewKey(graph);
281
+ const previousPositions = state.positionsByViewKey.get(viewKey) ?? state.positionsByDigest;
282
+ const nextContext = buildGraphContext(graph);
283
+ const preservePositions = isSameGraphContext(state.graphContext, nextContext);
284
+ const newDigests = new Set();
285
+
286
+ cy.elements().remove();
287
+ cy.add(
288
+ graph.nodes.map((node) => ({
289
+ group: "nodes",
290
+ data: {
291
+ id: node.id,
292
+ label: buildNodeLabel(node),
293
+ fullDigest: node.digest,
294
+ borderColor: nodeBorderColor(node),
295
+ nodeColor: kindFillColor(node.manifestKind)
296
+ },
297
+ classes: buildNodeClasses(node, graph),
298
+ position: resolveNodePosition(node, graph, preservePositions, previousPositions, mode, options)
299
+ }))
300
+ );
301
+ cy.add(
302
+ graph.edges.map((edge) => ({
303
+ group: "edges",
304
+ data: {
305
+ id: edge.id,
306
+ source: edge.from,
307
+ target: edge.to,
308
+ kind: edge.kind
309
+ }
310
+ }))
311
+ );
312
+
313
+ if (preservePositions) {
314
+ for (const node of graph.nodes) {
315
+ if (!previousPositions.has(node.digest)) {
316
+ newDigests.add(node.digest);
317
+ }
318
+ }
319
+ }
320
+
321
+ const layoutOptions =
322
+ mode === "expand"
323
+ ? { name: "preset", fit: true, padding: _GRAPH_PADDING }
324
+ : preservePositions && newDigests.size === 0
325
+ ? { name: "preset", fit: true, padding: _GRAPH_PADDING }
326
+ : { name: "cose", animate: false, fit: true, padding: _GRAPH_PADDING, randomize: false };
327
+ cy.layout(layoutOptions).run();
328
+ state.graphContext = nextContext;
329
+ state.positionsByDigest = captureNodePositions();
330
+ state.positionsByViewKey.set(viewKey, state.positionsByDigest);
331
+ syncSelectedNodeClass();
332
+ }
333
+
334
+ function buildNodeLabel(node) {
335
+ const primaryLine = kindLabel(node);
336
+ const secondaryLines = [];
337
+
338
+ if (node.displayPlatform) {
339
+ secondaryLines.push(node.displayPlatform);
340
+ }
341
+
342
+ secondaryLines.push(node.tags.length > 0 ? buildTagDisplayText(node.tags[0]) : `#${node.versionId}`);
343
+
344
+ if (node.tags.length > 1) {
345
+ secondaryLines.push(node.tags.slice(1).map(buildTagDisplayText).join(" | "));
346
+ }
347
+
348
+ return [primaryLine, "", ...secondaryLines].join("\n");
349
+ }
350
+
351
+ function shortDigest(digest) {
352
+ if (!digest.startsWith("sha256:")) {
353
+ return digest;
354
+ }
355
+
356
+ const value = digest.slice(7);
357
+ if (value.length <= 20) {
358
+ return digest;
359
+ }
360
+
361
+ return `sha256:${value.slice(0, 12)}...${value.slice(-8)}`;
362
+ }
363
+
364
+ function resolveUrl() {
365
+ const url = packageBaseUrl("/manifests");
366
+ appendOptionalScanParams(url);
367
+ url.searchParams.set(elements.lookupMode.value, elements.lookupValue.value.trim());
368
+ return url;
369
+ }
370
+
371
+ async function initializeSelectors() {
372
+ try {
373
+ setStatus("Loading owners...");
374
+ const owners = await fetchJson(new URL("/api/owners", window.location.origin));
375
+ replaceOptions(elements.owner, owners, "owner", "Select owner");
376
+ const ownerValues = owners.map((entry) => entry.owner);
377
+ elements.owner.value =
378
+ _pickInitialValue(elements.owner.value, ownerValues) || (ownerValues.length === 1 ? ownerValues[0] : "");
379
+ await handleOwnerChange({ preservePackageSelection: true, preserveScanSelection: true });
380
+ setStatus("");
381
+ } catch (error) {
382
+ if (error instanceof Error) {
383
+ setStatus(error.message);
384
+ }
385
+ }
386
+ }
387
+
388
+ async function handleOwnerChange(options = {}) {
389
+ const previousPackage = elements.packageName.value;
390
+ const previousScanId = elements.scanId.value;
391
+ const previousCompareScanId = elements.compareScanId.value;
392
+ resetSelect(elements.packageName, "Select package", true);
393
+ resetSelect(elements.scanId, "Latest completed scan", true);
394
+ resetSelect(elements.compareScanId, "None", true);
395
+ clearLookupSuggestions();
396
+
397
+ const owner = elements.owner.value;
398
+ if (!owner) {
399
+ return;
400
+ }
401
+
402
+ setStatus("Loading packages...");
403
+ const packages = await fetchJson(
404
+ new URL(`/api/owners/${encodeURIComponent(owner)}/packages`, window.location.origin)
405
+ );
406
+ replaceOptions(elements.packageName, packages, "packageName", "Select package");
407
+ const packageValues = packages.map((entry) => entry.packageName);
408
+ const initialPackage = options.preservePackageSelection
409
+ ? _pickInitialValue(previousPackage, packageValues) || (packageValues.length === 1 ? packageValues[0] : "")
410
+ : packageValues.length === 1
411
+ ? packageValues[0]
412
+ : "";
413
+ if (initialPackage) {
414
+ elements.packageName.value = initialPackage;
415
+ }
416
+
417
+ if (options.preserveScanSelection === true) {
418
+ elements.scanId.value = previousScanId;
419
+ elements.compareScanId.value = previousCompareScanId;
420
+ }
421
+ await handlePackageChange({
422
+ preserveScanSelection: options.preserveScanSelection === true,
423
+ previousScanId,
424
+ previousCompareScanId
425
+ });
426
+ }
427
+
428
+ async function handlePackageChange(options = {}) {
429
+ const previousScanId = options.previousScanId ?? elements.scanId.value;
430
+ const previousCompareScanId = options.previousCompareScanId ?? elements.compareScanId.value;
431
+ resetSelect(elements.scanId, "Latest completed scan", true);
432
+ resetSelect(elements.compareScanId, "None", true);
433
+ clearLookupSuggestions();
434
+
435
+ const owner = elements.owner.value;
436
+ const packageName = elements.packageName.value;
437
+ if (!owner || !packageName) {
438
+ return;
439
+ }
440
+
441
+ setStatus("Loading scans...");
442
+ const scans = await fetchJson(
443
+ new URL(
444
+ `/api/packages/${encodeURIComponent(owner)}/${encodeURIComponent(packageName)}/scans`,
445
+ window.location.origin
446
+ )
447
+ );
448
+ replaceScanOptions(scans, {
449
+ preserveSelection: options.preserveScanSelection === true,
450
+ previousScanId,
451
+ previousCompareScanId
452
+ });
453
+ setStatus("");
454
+ }
455
+
456
+ async function updateLookupSuggestions() {
457
+ if (elements.lookupMode.value !== "tag") {
458
+ clearLookupSuggestions();
459
+ return;
460
+ }
461
+
462
+ const owner = elements.owner.value;
463
+ const packageName = elements.packageName.value;
464
+ const query = elements.lookupValue.value.trim();
465
+ if (!owner || !packageName || query === "") {
466
+ clearLookupSuggestions();
467
+ return;
468
+ }
469
+
470
+ const requestId = ++state.lookupSuggestionRequestId;
471
+ const url = packageBaseUrl("/tags");
472
+ appendOptionalScanParams(url);
473
+ url.searchParams.set("q", query);
474
+ url.searchParams.set("limit", "20");
475
+ try {
476
+ const tags = await fetchJson(url);
477
+ if (requestId !== state.lookupSuggestionRequestId) {
478
+ return;
479
+ }
480
+
481
+ replaceLookupSuggestions(tags.map((entry) => entry.tagName));
482
+ } catch {
483
+ if (requestId === state.lookupSuggestionRequestId) {
484
+ clearLookupSuggestions();
485
+ }
486
+ }
487
+ }
488
+
489
+ function packageBaseUrl(suffix) {
490
+ const owner = encodeURIComponent(elements.owner.value.trim());
491
+ const packageName = encodeURIComponent(elements.packageName.value.trim());
492
+ return new URL(`/api/packages/${owner}/${packageName}${suffix}`, window.location.origin);
493
+ }
494
+
495
+ function appendOptionalScanParams(url) {
496
+ const scanId = elements.scanId.value.trim();
497
+ if (scanId) {
498
+ url.searchParams.set("scan_id", scanId);
499
+ }
500
+
501
+ const compareScanId = elements.compareScanId.value.trim();
502
+ if (compareScanId) {
503
+ url.searchParams.set("compare_scan_id", compareScanId);
504
+ }
505
+ }
506
+
507
+ async function fetchJson(url) {
508
+ const response = await fetch(url);
509
+ const body = await response.json();
510
+ if (!response.ok) {
511
+ const message = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`;
512
+ setStatus(message);
513
+ throw new Error(message);
514
+ }
515
+
516
+ return body;
517
+ }
518
+
519
+ function setStatus(message) {
520
+ elements.status.textContent = message;
521
+ }
522
+
523
+ function replaceLookupSuggestions(values) {
524
+ elements.lookupSuggestions.replaceChildren(...values.map((value) => buildOption(value, value)));
525
+ }
526
+
527
+ function clearLookupSuggestions() {
528
+ state.lookupSuggestionRequestId += 1;
529
+ elements.lookupSuggestions.replaceChildren();
530
+ }
531
+
532
+ function replaceOptions(select, entries, valueKey, placeholderLabel) {
533
+ const selectedValue = select.value;
534
+ select.replaceChildren(buildOption("", placeholderLabel));
535
+ for (const entry of entries) {
536
+ select.append(buildOption(entry[valueKey], entry[valueKey]));
537
+ }
538
+ select.disabled = entries.length === 0;
539
+ select.value = _pickInitialValue(
540
+ selectedValue,
541
+ entries.map((entry) => entry[valueKey])
542
+ );
543
+ }
544
+
545
+ function replaceScanOptions(scans, options = {}) {
546
+ const selectedScanId = options.preserveSelection ? (options.previousScanId ?? "") : "";
547
+ const selectedCompareScanId = options.preserveSelection ? (options.previousCompareScanId ?? "") : "";
548
+
549
+ elements.scanId.replaceChildren(buildOption("", "Latest completed scan"));
550
+ elements.compareScanId.replaceChildren(buildOption("", "None"));
551
+ for (const scan of scans) {
552
+ const label = formatScanLabel(scan);
553
+ elements.scanId.append(buildOption(String(scan.scanId), label));
554
+ elements.compareScanId.append(buildOption(String(scan.scanId), label));
555
+ }
556
+
557
+ const scanValues = scans.map((scan) => String(scan.scanId));
558
+ elements.scanId.disabled = scans.length === 0;
559
+ elements.compareScanId.disabled = scans.length === 0;
560
+ elements.scanId.value = _pickInitialValue(selectedScanId, scanValues) || _defaultScanId(scans);
561
+ elements.compareScanId.value =
562
+ _pickInitialValue(selectedCompareScanId, scanValues) || _defaultCompareScanId(scans, elements.scanId.value);
563
+ }
564
+
565
+ function buildOption(value, label) {
566
+ const option = document.createElement("option");
567
+ option.value = value;
568
+ option.textContent = label;
569
+ return option;
570
+ }
571
+
572
+ function resetSelect(select, placeholderLabel, disabled) {
573
+ select.replaceChildren(buildOption("", placeholderLabel));
574
+ select.disabled = disabled;
575
+ }
576
+
577
+ function formatScanLabel(scan) {
578
+ return `#${scan.scanId} ${scan.scanCompletedAt}`;
579
+ }
580
+
581
+ function _pickInitialValue(currentValue, allowedValues) {
582
+ return allowedValues.includes(currentValue) ? currentValue : "";
583
+ }
584
+
585
+ function _defaultScanId(scans) {
586
+ if (scans.length === 0) {
587
+ return "";
588
+ }
589
+
590
+ if (scans.length === 1) {
591
+ return String(scans[0].scanId);
592
+ }
593
+
594
+ return String(scans[1].scanId);
595
+ }
596
+
597
+ function _defaultCompareScanId(scans, selectedScanId) {
598
+ if (scans.length < 2) {
599
+ return "";
600
+ }
601
+
602
+ const newestScanId = String(scans[0].scanId);
603
+ return newestScanId === selectedScanId ? "" : newestScanId;
604
+ }
605
+
606
+ function buildGraphContext(graph) {
607
+ return {
608
+ owner: graph.owner,
609
+ packageName: graph.packageName,
610
+ scanId: graph.scanId,
611
+ compareScanId: graph.compareScanId ?? null,
612
+ centerDigest: graph.centerDigest
613
+ };
614
+ }
615
+
616
+ function buildGraphViewKey(graph) {
617
+ const digests = graph.nodes
618
+ .map((node) => node.digest)
619
+ .sort()
620
+ .join(",");
621
+ return `${graph.owner}/${graph.packageName}#${graph.scanId}#${graph.compareScanId ?? ""}#${graph.centerDigest}#${graph.depth}#${digests}`;
622
+ }
623
+
624
+ function isSameGraphContext(left, right) {
625
+ return (
626
+ left &&
627
+ right &&
628
+ left.owner === right.owner &&
629
+ left.packageName === right.packageName &&
630
+ left.scanId === right.scanId &&
631
+ left.compareScanId === right.compareScanId &&
632
+ left.centerDigest === right.centerDigest
633
+ );
634
+ }
635
+
636
+ function captureNodePositions() {
637
+ const positions = new Map();
638
+ for (const node of cy.nodes()) {
639
+ const position = node.position();
640
+ positions.set(node.id(), {
641
+ x: position.x,
642
+ y: position.y
643
+ });
644
+ }
645
+
646
+ return positions;
647
+ }
648
+
649
+ function persistCurrentLayoutState() {
650
+ if (!state.currentGraph) {
651
+ return;
652
+ }
653
+
654
+ const positions = captureNodePositions();
655
+ state.positionsByDigest = positions;
656
+ state.positionsByViewKey.set(buildGraphViewKey(state.currentGraph), positions);
657
+ }
658
+
659
+ function buildNodeClasses(node, graph) {
660
+ const classes = [];
661
+ if (node.digest === graph.centerDigest) {
662
+ classes.push("center");
663
+ }
664
+ if (node.changeStatus === "removed") {
665
+ classes.push("removed");
666
+ }
667
+ if (node.digest === state.selectedDigest) {
668
+ classes.push("selected");
669
+ }
670
+
671
+ return classes.join(" ");
672
+ }
673
+
674
+ function resolveNodePosition(node, graph, preservePositions, previousPositions, mode, options) {
675
+ if (!preservePositions) {
676
+ return undefined;
677
+ }
678
+
679
+ const existingPosition = previousPositions.get(node.digest);
680
+ if (existingPosition) {
681
+ return existingPosition;
682
+ }
683
+
684
+ if (mode !== "expand" || !options.expansionSourceDigest) {
685
+ return undefined;
686
+ }
687
+
688
+ return buildExpansionPosition(node.digest, options.expansionSourceDigest, graph, previousPositions);
689
+ }
690
+
691
+ function buildExpansionPosition(digest, expansionSourceDigest, graph, previousPositions) {
692
+ const sourcePosition = previousPositions.get(expansionSourceDigest);
693
+ if (!sourcePosition) {
694
+ return undefined;
695
+ }
696
+
697
+ const newDigests = graph.nodes
698
+ .map((node) => node.digest)
699
+ .filter((nodeDigest) => !previousPositions.has(nodeDigest))
700
+ .sort();
701
+ const index = newDigests.indexOf(digest);
702
+ if (index < 0) {
703
+ return undefined;
704
+ }
705
+
706
+ const angle = (Math.PI * 2 * index) / Math.max(newDigests.length, 1);
707
+ const radius = 180;
708
+ return {
709
+ x: sourcePosition.x + Math.cos(angle) * radius,
710
+ y: sourcePosition.y + Math.sin(angle) * radius
711
+ };
712
+ }
713
+
714
+ function mergeGraphs(currentGraph, expansionGraph) {
715
+ const nodesByDigest = new Map(currentGraph.nodes.map((node) => [node.digest, node]));
716
+ const edgesById = new Map(currentGraph.edges.map((edge) => [edge.id, edge]));
717
+
718
+ for (const node of expansionGraph.nodes) {
719
+ nodesByDigest.set(node.digest, node);
720
+ }
721
+ for (const edge of expansionGraph.edges) {
722
+ edgesById.set(edge.id, edge);
723
+ }
724
+
725
+ return {
726
+ ...currentGraph,
727
+ nodes: [...nodesByDigest.values()].sort((left, right) => left.digest.localeCompare(right.digest)),
728
+ edges: [...edgesById.values()].sort((left, right) => left.id.localeCompare(right.id))
729
+ };
730
+ }
731
+
732
+ function syncSelectedNodeClass() {
733
+ cy.nodes().removeClass("selected");
734
+ if (!state.selectedDigest) {
735
+ elements.expandNode.disabled = true;
736
+ elements.centerNode.disabled = true;
737
+ elements.showRawJson.disabled = true;
738
+ return;
739
+ }
740
+
741
+ const node = cy.getElementById(state.selectedDigest);
742
+ if (node.length > 0) {
743
+ node.addClass("selected");
744
+ }
745
+ }
746
+
747
+ function kindLabel(node) {
748
+ const manifestKind = node.manifestKind;
749
+ return kindShortLabel(manifestKind);
750
+ }
751
+
752
+ function buildTagDisplayText(tag) {
753
+ switch (tag.changeStatus) {
754
+ case "added":
755
+ return `(+) ${tag.name}`;
756
+ case "removed":
757
+ return `(-) ${tag.name}`;
758
+ default:
759
+ return tag.name;
760
+ }
761
+ }
762
+
763
+ function renderTagList(container, tags) {
764
+ container.replaceChildren();
765
+ container.classList.remove("tag-list");
766
+ if (tags.length === 0) {
767
+ container.textContent = "-";
768
+ return;
769
+ }
770
+
771
+ container.classList.add("tag-list");
772
+ for (const tag of tags) {
773
+ const tagElement = document.createElement("span");
774
+ tagElement.className = `tag ${tag.changeStatus}`;
775
+ tagElement.textContent = buildTagDisplayText(tag);
776
+ container.append(tagElement);
777
+ }
778
+ }
779
+
780
+ function kindShortLabel(manifestKind) {
781
+ switch (manifestKind) {
782
+ case "multi_arch_manifest":
783
+ return "multi-arch";
784
+ case "index_manifest":
785
+ return "index";
786
+ case "image_manifest":
787
+ return "image";
788
+ case "attestation_manifest":
789
+ return "attestation";
790
+ case "signature_manifest":
791
+ return "signature";
792
+ case "artifact_manifest":
793
+ return "artifact";
794
+ default:
795
+ return "unknown";
796
+ }
797
+ }
798
+
799
+ function zoomBy(factor) {
800
+ const currentZoom = cy.zoom();
801
+ const nextZoom = currentZoom * factor;
802
+ cy.zoom({
803
+ level: nextZoom,
804
+ renderedPosition: {
805
+ x: cy.width() / 2,
806
+ y: cy.height() / 2
807
+ }
808
+ });
809
+ }
810
+
811
+ function nodeBorderColor(node) {
812
+ switch (node.changeStatus) {
813
+ case "added":
814
+ return "#0b8f3a";
815
+ case "removed":
816
+ return "#d32f2f";
817
+ default:
818
+ return "#6b7280";
819
+ }
820
+ }
821
+
822
+ function kindFillColor(manifestKind) {
823
+ switch (manifestKind) {
824
+ case "multi_arch_manifest":
825
+ return "#c6e0ff";
826
+ case "index_manifest":
827
+ return "#c8efe6";
828
+ case "image_manifest":
829
+ return "#cdeece";
830
+ case "attestation_manifest":
831
+ return "#f6dbb2";
832
+ case "signature_manifest":
833
+ return "#e8cdf8";
834
+ case "artifact_manifest":
835
+ return "#d8dce0";
836
+ default:
837
+ return "#d1dfd7";
838
+ }
839
+ }