cognite-neat 1.0.6__py3-none-any.whl → 1.0.8__py3-none-any.whl
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.
- cognite/neat/_client/containers_api.py +30 -17
- cognite/neat/_client/views_api.py +29 -14
- cognite/neat/_data_model/deployer/data_classes.py +1 -1
- cognite/neat/_data_model/models/dms/_view_filter.py +3 -0
- cognite/neat/_session/_html/static/deployment.css +173 -0
- cognite/neat/_session/_html/static/deployment.js +36 -5
- cognite/neat/_session/_html/templates/deployment.html +8 -3
- cognite/neat/_session/_result/__init__.py +3 -0
- cognite/neat/_session/_result/_deployment/__init__.py +0 -0
- cognite/neat/_session/_result/_deployment/_physical/__init__.py +0 -0
- cognite/neat/_session/_result/_deployment/_physical/_changes.py +195 -0
- cognite/neat/_session/_result/_deployment/_physical/_statistics.py +180 -0
- cognite/neat/_session/_result/_deployment/_physical/serializer.py +35 -0
- cognite/neat/_session/_result/_result.py +31 -0
- cognite/neat/_version.py +1 -1
- {cognite_neat-1.0.6.dist-info → cognite_neat-1.0.8.dist-info}/METADATA +42 -42
- {cognite_neat-1.0.6.dist-info → cognite_neat-1.0.8.dist-info}/RECORD +47 -42
- cognite_neat-1.0.8.dist-info/WHEEL +4 -0
- cognite/neat/_session/_result.py +0 -236
- cognite_neat-1.0.6.dist-info/WHEEL +0 -4
- cognite_neat-1.0.6.dist-info/licenses/LICENSE +0 -201
|
@@ -12,6 +12,7 @@ from .data_classes import PagedResponse
|
|
|
12
12
|
|
|
13
13
|
class ContainersAPI(NeatAPI):
|
|
14
14
|
ENDPOINT = "/models/containers"
|
|
15
|
+
LIST_REQUEST_LIMIT = 1000
|
|
15
16
|
|
|
16
17
|
def apply(self, items: Sequence[ContainerRequest]) -> list[ContainerResponse]:
|
|
17
18
|
"""Apply (create or update) containers in CDF.
|
|
@@ -93,33 +94,45 @@ class ContainersAPI(NeatAPI):
|
|
|
93
94
|
self,
|
|
94
95
|
space: str | None = None,
|
|
95
96
|
include_global: bool = False,
|
|
96
|
-
limit: int = 10,
|
|
97
|
+
limit: int | None = 10,
|
|
97
98
|
) -> list[ContainerResponse]:
|
|
98
99
|
"""List containers in CDF Project.
|
|
99
100
|
|
|
100
101
|
Args:
|
|
101
102
|
space: If specified, only containers in this space are returned.
|
|
102
103
|
include_global: If True, include global containers.
|
|
103
|
-
limit: Maximum number of containers to return.
|
|
104
|
+
limit: Maximum number of containers to return. If None, return all containers.
|
|
104
105
|
|
|
105
106
|
Returns:
|
|
106
107
|
List of ContainerResponse objects.
|
|
107
108
|
"""
|
|
108
|
-
if limit
|
|
109
|
-
raise ValueError("
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
109
|
+
if limit is not None and limit < 0:
|
|
110
|
+
raise ValueError("Limit must be non-negative.")
|
|
111
|
+
elif limit is not None and limit == 0:
|
|
112
|
+
return []
|
|
113
|
+
parameters: dict[str, PrimitiveType] = {"includeGlobal": include_global}
|
|
114
114
|
if space is not None:
|
|
115
115
|
parameters["space"] = space
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
parameters=
|
|
116
|
+
cursor: str | None = None
|
|
117
|
+
container_responses: list[ContainerResponse] = []
|
|
118
|
+
while True:
|
|
119
|
+
if cursor is not None:
|
|
120
|
+
parameters["cursor"] = cursor
|
|
121
|
+
if limit is None:
|
|
122
|
+
parameters["limit"] = self.LIST_REQUEST_LIMIT
|
|
123
|
+
else:
|
|
124
|
+
parameters["limit"] = min(self.LIST_REQUEST_LIMIT, limit - len(container_responses))
|
|
125
|
+
result = self._http_client.request_with_retries(
|
|
126
|
+
ParametersRequest(
|
|
127
|
+
endpoint_url=self._config.create_api_url(self.ENDPOINT),
|
|
128
|
+
method="GET",
|
|
129
|
+
parameters=parameters,
|
|
130
|
+
)
|
|
121
131
|
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
result.raise_for_status()
|
|
133
|
+
result = PagedResponse[ContainerResponse].model_validate_json(result.success_response.body)
|
|
134
|
+
container_responses.extend(result.items)
|
|
135
|
+
cursor = result.next_cursor
|
|
136
|
+
if cursor is None or (limit is not None and len(container_responses) >= limit):
|
|
137
|
+
break
|
|
138
|
+
return container_responses
|
|
@@ -13,6 +13,7 @@ from .data_classes import PagedResponse
|
|
|
13
13
|
|
|
14
14
|
class ViewsAPI(NeatAPI):
|
|
15
15
|
ENDPOINT = "/models/views"
|
|
16
|
+
LIST_REQUEST_LIMIT = 1000
|
|
16
17
|
|
|
17
18
|
def apply(self, items: Sequence[ViewRequest]) -> list[ViewResponse]:
|
|
18
19
|
"""Create or update views in CDF Project.
|
|
@@ -93,7 +94,7 @@ class ViewsAPI(NeatAPI):
|
|
|
93
94
|
all_versions: bool = False,
|
|
94
95
|
include_inherited_properties: bool = True,
|
|
95
96
|
include_global: bool = False,
|
|
96
|
-
limit: int = 10,
|
|
97
|
+
limit: int | None = 10,
|
|
97
98
|
) -> list[ViewResponse]:
|
|
98
99
|
"""List views in CDF Project.
|
|
99
100
|
|
|
@@ -102,28 +103,42 @@ class ViewsAPI(NeatAPI):
|
|
|
102
103
|
all_versions: If True, return all versions. If False, only return the latest version.
|
|
103
104
|
include_inherited_properties: If True, include properties inherited from parent views.
|
|
104
105
|
include_global: If True, include global views.
|
|
105
|
-
limit: Maximum number of views to return.
|
|
106
|
+
limit: Maximum number of views to return. If None, return all views.
|
|
106
107
|
|
|
107
108
|
Returns:
|
|
108
109
|
List of ViewResponse objects.
|
|
109
110
|
"""
|
|
110
|
-
if limit
|
|
111
|
-
raise ValueError("
|
|
111
|
+
if limit is not None and limit < 0:
|
|
112
|
+
raise ValueError("Limit must be non-negative.")
|
|
113
|
+
elif limit is not None and limit == 0:
|
|
114
|
+
return []
|
|
112
115
|
parameters: dict[str, PrimitiveType] = {
|
|
113
116
|
"allVersions": all_versions,
|
|
114
117
|
"includeInheritedProperties": include_inherited_properties,
|
|
115
118
|
"includeGlobal": include_global,
|
|
116
|
-
"limit": limit,
|
|
117
119
|
}
|
|
118
120
|
if space is not None:
|
|
119
121
|
parameters["space"] = space
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
parameters=
|
|
122
|
+
cursor: str | None = None
|
|
123
|
+
view_responses: list[ViewResponse] = []
|
|
124
|
+
while True:
|
|
125
|
+
if cursor is not None:
|
|
126
|
+
parameters["cursor"] = cursor
|
|
127
|
+
if limit is None:
|
|
128
|
+
parameters["limit"] = self.LIST_REQUEST_LIMIT
|
|
129
|
+
else:
|
|
130
|
+
parameters["limit"] = min(self.LIST_REQUEST_LIMIT, limit - len(view_responses))
|
|
131
|
+
result = self._http_client.request_with_retries(
|
|
132
|
+
ParametersRequest(
|
|
133
|
+
endpoint_url=self._config.create_api_url(self.ENDPOINT),
|
|
134
|
+
method="GET",
|
|
135
|
+
parameters=parameters,
|
|
136
|
+
)
|
|
125
137
|
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
result.raise_for_status()
|
|
139
|
+
result = PagedResponse[ViewResponse].model_validate_json(result.success_response.body)
|
|
140
|
+
view_responses.extend(result.items)
|
|
141
|
+
cursor = result.next_cursor
|
|
142
|
+
if cursor is None or (limit is not None and len(view_responses) >= limit):
|
|
143
|
+
break
|
|
144
|
+
return view_responses
|
|
@@ -254,6 +254,9 @@ def _move_filter_key(value: Any) -> Any:
|
|
|
254
254
|
# Already in the correct format
|
|
255
255
|
return value
|
|
256
256
|
key, data = next(iter(value.items()))
|
|
257
|
+
# Check if inner data already has filterType (already processed by a previous recursive call)
|
|
258
|
+
if isinstance(data, dict) and "filterType" in data:
|
|
259
|
+
return value
|
|
257
260
|
if key not in AVAILABLE_FILTERS:
|
|
258
261
|
raise ValueError(
|
|
259
262
|
f"Unknown filter type: {key!r}. Available filter types: {humanize_collection(AVAILABLE_FILTERS)}."
|
|
@@ -132,6 +132,16 @@
|
|
|
132
132
|
color: #9ca3af;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
.change-failed {
|
|
136
|
+
background: #fee2e2;
|
|
137
|
+
color: #991b1b;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.dark-mode .change-failed {
|
|
141
|
+
background: #7f1d1d;
|
|
142
|
+
color: #fca5a5;
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
.severity-badge {
|
|
136
146
|
padding: 4px 10px;
|
|
137
147
|
border-radius: 4px;
|
|
@@ -286,6 +296,16 @@
|
|
|
286
296
|
color: white;
|
|
287
297
|
}
|
|
288
298
|
|
|
299
|
+
.status-recovered {
|
|
300
|
+
background: #10b981;
|
|
301
|
+
color: white;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.status-recovery_failed {
|
|
305
|
+
background: #dc2626;
|
|
306
|
+
color: white;
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
.dark-mode .status-success {
|
|
290
310
|
background: #059669;
|
|
291
311
|
}
|
|
@@ -300,4 +320,157 @@
|
|
|
300
320
|
|
|
301
321
|
.dark-mode .status-pending {
|
|
302
322
|
background: #2563eb;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.dark-mode .status-recovered {
|
|
326
|
+
background: #059669;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.dark-mode .status-recovery_failed {
|
|
330
|
+
background: #b91c1c;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
/* Error message handling */
|
|
335
|
+
|
|
336
|
+
.error-message-box {
|
|
337
|
+
background: #fee2e2;
|
|
338
|
+
border: 2px solid #dc2626;
|
|
339
|
+
border-left: 6px solid #dc2626;
|
|
340
|
+
border-radius: 8px;
|
|
341
|
+
padding: 16px;
|
|
342
|
+
margin-top: 12px;
|
|
343
|
+
animation: slideIn 0.3s ease-out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.dark-mode .error-message-box {
|
|
347
|
+
background: #7f1d1d;
|
|
348
|
+
border-color: #ef4444;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.error-message-header {
|
|
352
|
+
display: flex;
|
|
353
|
+
align-items: center;
|
|
354
|
+
gap: 10px;
|
|
355
|
+
margin-bottom: 8px;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.error-icon {
|
|
359
|
+
font-size: 24px;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.error-title {
|
|
363
|
+
font-weight: 700;
|
|
364
|
+
font-size: 14px;
|
|
365
|
+
color: #991b1b;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.dark-mode .error-title {
|
|
369
|
+
color: #fca5a5;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.error-message-content {
|
|
373
|
+
color: #7f1d1d;
|
|
374
|
+
font-size: 13px;
|
|
375
|
+
line-height: 1.6;
|
|
376
|
+
padding-left: 34px;
|
|
377
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.dark-mode .error-message-content {
|
|
381
|
+
color: #fca5a5;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.change-item.failed-change {
|
|
385
|
+
border-left: 4px solid #dc2626;
|
|
386
|
+
background: rgba(254, 226, 226, 0.1);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.dark-mode .change-item.failed-change {
|
|
390
|
+
border-left-color: #ef4444;
|
|
391
|
+
background: rgba(127, 29, 29, 0.2);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.info-message-box {
|
|
395
|
+
border-radius: 6px;
|
|
396
|
+
padding: 12px 16px;
|
|
397
|
+
margin-top: 12px;
|
|
398
|
+
font-size: 13px;
|
|
399
|
+
line-height: 1.6;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.info-message-content {
|
|
403
|
+
color: inherit;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.info-message-create {
|
|
407
|
+
background: #d1fae5;
|
|
408
|
+
border-left: 4px solid #065f46;
|
|
409
|
+
color: #065f46;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.info-message-update {
|
|
413
|
+
background: #dbeafe;
|
|
414
|
+
border-left: 4px solid #1e40af;
|
|
415
|
+
color: #1e40af;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.info-message-delete {
|
|
419
|
+
background: #fee2e2;
|
|
420
|
+
border-left: 4px solid #991b1b;
|
|
421
|
+
color: #991b1b;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.info-message-skip {
|
|
425
|
+
background: #fef3c7;
|
|
426
|
+
border-left: 4px solid #92400e;
|
|
427
|
+
color: #92400e;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.info-message-unchanged {
|
|
431
|
+
background: #f3f4f6;
|
|
432
|
+
border-left: 4px solid #4b5563;
|
|
433
|
+
color: #4b5563;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.dark-mode .info-message-create {
|
|
437
|
+
background: #064e3b;
|
|
438
|
+
border-left-color: #6ee7b7;
|
|
439
|
+
color: #6ee7b7;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.dark-mode .info-message-update {
|
|
443
|
+
background: #1e3a8a;
|
|
444
|
+
border-left-color: #93c5fd;
|
|
445
|
+
color: #93c5fd;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.dark-mode .info-message-delete {
|
|
449
|
+
background: #7f1d1d;
|
|
450
|
+
border-left-color: #fca5a5;
|
|
451
|
+
color: #fca5a5;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.dark-mode .info-message-skip {
|
|
455
|
+
background: #78350f;
|
|
456
|
+
border-left-color: #fde68a;
|
|
457
|
+
color: #fde68a;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.dark-mode .info-message-unchanged {
|
|
461
|
+
background: #374151;
|
|
462
|
+
border-left-color: #9ca3af;
|
|
463
|
+
color: #9ca3af;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@keyframes slideIn {
|
|
468
|
+
from {
|
|
469
|
+
opacity: 0;
|
|
470
|
+
transform: translateY(-10px);
|
|
471
|
+
}
|
|
472
|
+
to {
|
|
473
|
+
opacity: 1;
|
|
474
|
+
transform: translateY(0);
|
|
475
|
+
}
|
|
303
476
|
}
|
|
@@ -17,7 +17,9 @@ const STATUS_CONFIG = {
|
|
|
17
17
|
success: { icon: '✅', text: 'Success' },
|
|
18
18
|
failure: { icon: '❌', text: 'Failure' },
|
|
19
19
|
partial: { icon: '⚠️', text: 'Partial Success' },
|
|
20
|
-
pending: { icon: '⏳', text: 'Pending (Dry Run)' }
|
|
20
|
+
pending: { icon: '⏳', text: 'Pending (Dry Run)' },
|
|
21
|
+
recovered: { icon: '🔄', text: 'Recovered' },
|
|
22
|
+
recovery_failed: { icon: '💔', text: 'Recovery Failed' }
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
// Initialize status badge
|
|
@@ -26,7 +28,7 @@ function initializeStatus() {
|
|
|
26
28
|
const statusIcon = document.getElementById('statusIcon-' + uniqueId);
|
|
27
29
|
const statusText = document.getElementById('statusText-' + uniqueId);
|
|
28
30
|
|
|
29
|
-
const statusConfig = STATUS_CONFIG[
|
|
31
|
+
const statusConfig = STATUS_CONFIG[deploymentStatus] || STATUS_CONFIG.pending;
|
|
30
32
|
statusIcon.textContent = statusConfig.icon;
|
|
31
33
|
statusText.textContent = statusConfig.text;
|
|
32
34
|
}
|
|
@@ -43,6 +45,31 @@ function updateTheme() {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function renderChangeMessage(change) {
|
|
49
|
+
if (!change.message) return '';
|
|
50
|
+
|
|
51
|
+
// Using textContent to set the message prevents XSS vulnerabilities
|
|
52
|
+
// by automatically escaping HTML characters.
|
|
53
|
+
const messageHolder = document.createElement('div');
|
|
54
|
+
messageHolder.textContent = change.message;
|
|
55
|
+
const escapedMessage = messageHolder.innerHTML;
|
|
56
|
+
|
|
57
|
+
if (change.change_type === 'failed') {
|
|
58
|
+
return `
|
|
59
|
+
<div class="error-message-box">
|
|
60
|
+
<div class="error-message-header">
|
|
61
|
+
<span class="error-icon">❌</span>
|
|
62
|
+
<span class="error-title">Deployment Failed</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="error-message-content">${escapedMessage}</div>
|
|
65
|
+
</div>`;
|
|
66
|
+
}
|
|
67
|
+
return `
|
|
68
|
+
<div class="info-message-box info-message-${change.change_type}">
|
|
69
|
+
<div class="info-message-content">${escapedMessage}</div>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
updateTheme();
|
|
47
74
|
initializeStatus();
|
|
48
75
|
|
|
@@ -76,13 +103,14 @@ function renderChanges() {
|
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
listContainer.innerHTML = filtered.map(change => `
|
|
79
|
-
<div class="change-item">
|
|
106
|
+
<div class="change-item ${change.change_type === 'failed' ? 'failed-change' : ''}">
|
|
80
107
|
<div class="change-header">
|
|
81
108
|
<span class="endpoint-badge endpoint-${change.endpoint}">${change.endpoint}</span>
|
|
82
109
|
<span class="change-type-badge change-${change.change_type}">${change.change_type}</span>
|
|
83
110
|
<span class="severity-badge severity-${change.severity}">${change.severity}</span>
|
|
84
111
|
</div>
|
|
85
112
|
<div class="resource-id">${change.resource_id}</div>
|
|
113
|
+
|
|
86
114
|
${change.changes.length > 0 ? `
|
|
87
115
|
<div class="field-changes">
|
|
88
116
|
${change.changes.map(fc => `
|
|
@@ -93,6 +121,9 @@ function renderChanges() {
|
|
|
93
121
|
`).join('')}
|
|
94
122
|
</div>
|
|
95
123
|
` : ''}
|
|
124
|
+
|
|
125
|
+
${renderChangeMessage(change)}
|
|
126
|
+
|
|
96
127
|
</div>
|
|
97
128
|
`).join('');
|
|
98
129
|
}
|
|
@@ -130,8 +161,8 @@ document.getElementById('searchInput-' + uniqueId).addEventListener('input', fun
|
|
|
130
161
|
window['exportDeployment_' + uniqueId] = function() {
|
|
131
162
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
132
163
|
const report = {
|
|
133
|
-
status:
|
|
134
|
-
is_dry_run:
|
|
164
|
+
status: deploymentStatus,
|
|
165
|
+
is_dry_run: is_dry_run === 'True',
|
|
135
166
|
timestamp: timestamp,
|
|
136
167
|
statistics: stats,
|
|
137
168
|
changes: changes,
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
<div class="stat-item active" data-filter="all">
|
|
21
21
|
<span class="stat-number">{{total_changes}}</span> Total Changes
|
|
22
22
|
</div>
|
|
23
|
+
<div class="stat-item" data-filter="failed">
|
|
24
|
+
<span class="stat-number">{{failed}}</span> Failed
|
|
25
|
+
</div>
|
|
23
26
|
<div class="stat-item" data-filter="create">
|
|
24
27
|
<span class="stat-number">{{created}}</span> Created
|
|
25
28
|
</div>
|
|
@@ -29,12 +32,12 @@
|
|
|
29
32
|
<div class="stat-item" data-filter="delete">
|
|
30
33
|
<span class="stat-number">{{deleted}}</span> Deleted
|
|
31
34
|
</div>
|
|
32
|
-
<div class="stat-item" data-filter="skip">
|
|
33
|
-
<span class="stat-number">{{skipped}}</span> Skipped
|
|
34
|
-
</div>
|
|
35
35
|
<div class="stat-item" data-filter="unchanged">
|
|
36
36
|
<span class="stat-number">{{unchanged}}</span> Unchanged
|
|
37
37
|
</div>
|
|
38
|
+
<div class="stat-item" data-filter="skip">
|
|
39
|
+
<span class="stat-number">{{skipped}}</span> Skipped
|
|
40
|
+
</div>
|
|
38
41
|
<button class="export-btn" onclick="exportDeployment_{{unique_id}}()">Export Report</button>
|
|
39
42
|
</div>
|
|
40
43
|
</div>
|
|
@@ -70,6 +73,8 @@
|
|
|
70
73
|
const changes = {{CHANGES_JSON}};
|
|
71
74
|
const stats = {{STATS_JSON}};
|
|
72
75
|
const uniqueId = '{{unique_id}}';
|
|
76
|
+
const is_dry_run = '{{is_dry_run}}';
|
|
77
|
+
const deploymentStatus = '{{status}}';
|
|
73
78
|
{{SCRIPTS}}
|
|
74
79
|
})();
|
|
75
80
|
</script>
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from cognite.neat._data_model.deployer.data_classes import (
|
|
7
|
+
AddedField,
|
|
8
|
+
AppliedChanges,
|
|
9
|
+
ChangedField,
|
|
10
|
+
ChangeResult,
|
|
11
|
+
DeploymentResult,
|
|
12
|
+
FieldChange,
|
|
13
|
+
FieldChanges,
|
|
14
|
+
RemovedField,
|
|
15
|
+
ResourceChange,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SerializedFieldChange(BaseModel):
|
|
20
|
+
"""Serialized field change for JSON output."""
|
|
21
|
+
|
|
22
|
+
field_path: str
|
|
23
|
+
severity: str
|
|
24
|
+
description: str
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_field_change(cls, field_change: FieldChange) -> list["SerializedFieldChange"]:
|
|
28
|
+
"""Serialize a field change, handling nested FieldChanges recursively.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
field_change: The field change to serialize.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of serialized field changes (may be multiple if nested).
|
|
35
|
+
"""
|
|
36
|
+
serialized_changes: list[SerializedFieldChange] = []
|
|
37
|
+
|
|
38
|
+
if isinstance(field_change, FieldChanges):
|
|
39
|
+
# Recursively handle nested changes
|
|
40
|
+
for change in field_change.changes:
|
|
41
|
+
serialized_changes.extend(cls.from_field_change(change))
|
|
42
|
+
else:
|
|
43
|
+
# Base case: single field change
|
|
44
|
+
serialized_changes.append(cls._from_single_field_change(field_change))
|
|
45
|
+
|
|
46
|
+
return serialized_changes
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _from_single_field_change(cls, field_change: FieldChange) -> "SerializedFieldChange":
|
|
50
|
+
"""Serialize a single non-nested field change.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
field_change: The single field change to serialize.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Serialized field change.
|
|
57
|
+
"""
|
|
58
|
+
return cls(
|
|
59
|
+
field_path=field_change.field_path,
|
|
60
|
+
severity=field_change.severity.name,
|
|
61
|
+
description=field_change.description
|
|
62
|
+
if isinstance(field_change, AddedField | RemovedField | ChangedField)
|
|
63
|
+
else "Field changed",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SerializedResourceChange(BaseModel):
|
|
68
|
+
"""Serialized resource change for JSON output."""
|
|
69
|
+
|
|
70
|
+
id: int
|
|
71
|
+
endpoint: str
|
|
72
|
+
change_type: str
|
|
73
|
+
severity: str
|
|
74
|
+
resource_id: str
|
|
75
|
+
message: str | None = None
|
|
76
|
+
changes: list[SerializedFieldChange] = Field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_resource_change(
|
|
80
|
+
cls, resource: ResourceChange, endpoint: str, change_id: int
|
|
81
|
+
) -> "SerializedResourceChange":
|
|
82
|
+
"""Serialize a single resource change.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
resource: The resource change to serialize.
|
|
86
|
+
endpoint: The endpoint type.
|
|
87
|
+
change_id: Unique ID for this change.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Serialized resource change.
|
|
91
|
+
"""
|
|
92
|
+
changes: list[SerializedFieldChange] = []
|
|
93
|
+
|
|
94
|
+
for change in resource.changes:
|
|
95
|
+
changes.extend(SerializedFieldChange.from_field_change(change))
|
|
96
|
+
|
|
97
|
+
return cls(
|
|
98
|
+
id=change_id,
|
|
99
|
+
endpoint=endpoint,
|
|
100
|
+
change_type=resource.change_type,
|
|
101
|
+
severity=resource.severity.name,
|
|
102
|
+
resource_id=str(resource.resource_id),
|
|
103
|
+
changes=changes,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_change_result(cls, change_id: int, response: ChangeResult) -> "SerializedResourceChange":
|
|
108
|
+
"""Serialize from a change result (actual deployment).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
change_id: Unique ID for this change.
|
|
112
|
+
response: The change result from deployment.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Serialized resource change with deployment status.
|
|
116
|
+
"""
|
|
117
|
+
serialized_resource_change = cls.from_resource_change(
|
|
118
|
+
resource=response.change,
|
|
119
|
+
endpoint=response.endpoint,
|
|
120
|
+
change_id=change_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
serialized_resource_change.message = response.message
|
|
124
|
+
if not response.is_success:
|
|
125
|
+
serialized_resource_change.change_type = "failed"
|
|
126
|
+
|
|
127
|
+
return serialized_resource_change
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SerializedChanges(BaseModel):
|
|
131
|
+
"""Container for all serialized changes."""
|
|
132
|
+
|
|
133
|
+
changes: list[SerializedResourceChange] = Field(default_factory=list)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_deployment_result(cls, result: DeploymentResult) -> "SerializedChanges":
|
|
137
|
+
"""Create SerializedChanges from a DeploymentResult.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
result: The deployment result to serialize changes from.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
SerializedChanges instance with all changes.
|
|
144
|
+
"""
|
|
145
|
+
serialized = cls()
|
|
146
|
+
|
|
147
|
+
if not result.responses:
|
|
148
|
+
serialized._add_from_dry_run(result)
|
|
149
|
+
else:
|
|
150
|
+
serialized._add_from_applied_changes(result.responses)
|
|
151
|
+
|
|
152
|
+
return serialized
|
|
153
|
+
|
|
154
|
+
def _add_from_dry_run(self, result: DeploymentResult) -> None:
|
|
155
|
+
"""Add changes from dry run deployment.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
result: The deployment result in dry run mode.
|
|
159
|
+
"""
|
|
160
|
+
# Iterate over each endpoint plan
|
|
161
|
+
for endpoint_plan in result.plan:
|
|
162
|
+
# Then per resource in the endpoint
|
|
163
|
+
for resource in endpoint_plan.resources:
|
|
164
|
+
# Then serialize individual resource change
|
|
165
|
+
serialized_resource_change = SerializedResourceChange.from_resource_change(
|
|
166
|
+
resource=resource,
|
|
167
|
+
endpoint=endpoint_plan.endpoint,
|
|
168
|
+
change_id=len(self.changes),
|
|
169
|
+
)
|
|
170
|
+
self.changes.append(serialized_resource_change)
|
|
171
|
+
|
|
172
|
+
def _add_from_applied_changes(self, applied_changes: AppliedChanges) -> None:
|
|
173
|
+
"""Add changes from actual deployment.
|
|
174
|
+
Args:
|
|
175
|
+
result: The deployment result from actual deployment.
|
|
176
|
+
"""
|
|
177
|
+
for response in itertools.chain(
|
|
178
|
+
applied_changes.created,
|
|
179
|
+
applied_changes.merged_updated,
|
|
180
|
+
applied_changes.deletions,
|
|
181
|
+
applied_changes.unchanged,
|
|
182
|
+
applied_changes.skipped,
|
|
183
|
+
):
|
|
184
|
+
self.changes.append(SerializedResourceChange.from_change_result(len(self.changes), response))
|
|
185
|
+
|
|
186
|
+
def model_dump_json_flat(self, **kwargs: Any) -> str:
|
|
187
|
+
"""Dump changes as JSON array without the wrapper key.
|
|
188
|
+
Returns:
|
|
189
|
+
JSON string of the changes array.
|
|
190
|
+
"""
|
|
191
|
+
if not self.changes:
|
|
192
|
+
return "[]"
|
|
193
|
+
|
|
194
|
+
iterator = (change.model_dump_json(**kwargs) for change in self.changes)
|
|
195
|
+
return f"[{','.join(iterator)}]"
|