cognite-neat 1.0.5__py3-none-any.whl → 1.0.7__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/_data_model/deployer/data_classes.py +117 -14
- cognite/neat/_data_model/deployer/deployer.py +55 -18
- 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.5.dist-info → cognite_neat-1.0.7.dist-info}/METADATA +42 -42
- {cognite_neat-1.0.5.dist-info → cognite_neat-1.0.7.dist-info}/RECORD +45 -40
- cognite_neat-1.0.7.dist-info/WHEEL +4 -0
- cognite/neat/_session/_result.py +0 -236
- cognite_neat-1.0.5.dist-info/WHEEL +0 -4
- cognite_neat-1.0.5.dist-info/licenses/LICENSE +0 -201
|
@@ -2,6 +2,7 @@ import itertools
|
|
|
2
2
|
import sys
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from collections import UserList, defaultdict
|
|
5
|
+
from collections.abc import Hashable, Sequence
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from enum import Enum
|
|
7
8
|
from typing import Any, Generic, Literal, TypeAlias, cast
|
|
@@ -379,15 +380,88 @@ class ResourceDeploymentPlanList(UserList[ResourceDeploymentPlan]):
|
|
|
379
380
|
return type(self)(forced_plans)
|
|
380
381
|
|
|
381
382
|
|
|
382
|
-
class ChangeResult(BaseDeployObject, Generic[T_ResourceId, T_DataModelResource]):
|
|
383
|
+
class ChangeResult(BaseDeployObject, Generic[T_ResourceId, T_DataModelResource], ABC):
|
|
383
384
|
endpoint: DataModelEndpoint
|
|
384
385
|
change: ResourceChange[T_ResourceId, T_DataModelResource]
|
|
385
|
-
message: SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
|
|
386
386
|
|
|
387
|
+
@property
|
|
388
|
+
@abstractmethod
|
|
389
|
+
def message(self) -> str:
|
|
390
|
+
"""Human-readable message about the change result."""
|
|
391
|
+
...
|
|
387
392
|
|
|
388
|
-
|
|
393
|
+
@property
|
|
394
|
+
@abstractmethod
|
|
395
|
+
def is_success(self) -> bool:
|
|
396
|
+
"""Whether the change was successful."""
|
|
397
|
+
...
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class HTTPChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
|
|
401
|
+
http_message: (
|
|
402
|
+
SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def message(self) -> str:
|
|
407
|
+
if isinstance(self.http_message, SuccessResponse):
|
|
408
|
+
return "Success"
|
|
409
|
+
elif isinstance(self.http_message, FailedResponseItems):
|
|
410
|
+
error = self.http_message.error
|
|
411
|
+
return f"Failed: {error.code} | {error.message}"
|
|
412
|
+
elif isinstance(self.http_message, FailedRequestItems):
|
|
413
|
+
return f"Request Failed: {self.http_message.message}"
|
|
414
|
+
else:
|
|
415
|
+
return "Unknown result"
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def is_success(self) -> bool:
|
|
419
|
+
return isinstance(self.http_message, SuccessResponse)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class MultiHTTPChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
|
|
423
|
+
http_messages: list[
|
|
424
|
+
SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def message(self) -> str:
|
|
429
|
+
error_messages: list[str] = []
|
|
430
|
+
for msg in self.http_messages:
|
|
431
|
+
if isinstance(msg, SuccessResponse):
|
|
432
|
+
continue
|
|
433
|
+
elif isinstance(msg, FailedResponseItems):
|
|
434
|
+
error = msg.error
|
|
435
|
+
error_messages.append(f"Failed: {error.code} | {error.message}")
|
|
436
|
+
elif isinstance(msg, FailedRequestItems):
|
|
437
|
+
error_messages.append(f"Request Failed: {msg.message}")
|
|
438
|
+
if not error_messages:
|
|
439
|
+
return "Success"
|
|
440
|
+
return "; ".join(error_messages)
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def is_success(self) -> bool:
|
|
444
|
+
return all(isinstance(msg, SuccessResponse) for msg in self.http_messages)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class NoOpChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
|
|
448
|
+
"""A change result representing a no-op, e.g., when a change was skipped or unchanged."""
|
|
449
|
+
|
|
450
|
+
reason: str
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def message(self) -> str:
|
|
454
|
+
return self.reason
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def is_success(self) -> bool:
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class ChangedFieldResult(BaseDeployObject, Generic[T_ResourceId, T_Reference]):
|
|
462
|
+
resource_id: T_ResourceId
|
|
389
463
|
field_change: FieldChange
|
|
390
|
-
|
|
464
|
+
http_message: SuccessResponseItems[T_Reference] | FailedResponseItems[T_Reference] | FailedRequestItems[T_Reference]
|
|
391
465
|
|
|
392
466
|
|
|
393
467
|
class AppliedChanges(BaseDeployObject):
|
|
@@ -399,26 +473,55 @@ class AppliedChanges(BaseDeployObject):
|
|
|
399
473
|
This is needed as these changes are done with a separate API call per change.
|
|
400
474
|
"""
|
|
401
475
|
|
|
402
|
-
created: list[
|
|
403
|
-
updated: list[
|
|
404
|
-
deletions: list[
|
|
405
|
-
unchanged: list[
|
|
406
|
-
skipped: list[
|
|
476
|
+
created: list[HTTPChangeResult] = Field(default_factory=list)
|
|
477
|
+
updated: list[HTTPChangeResult] = Field(default_factory=list)
|
|
478
|
+
deletions: list[HTTPChangeResult] = Field(default_factory=list)
|
|
479
|
+
unchanged: list[NoOpChangeResult] = Field(default_factory=list)
|
|
480
|
+
skipped: list[NoOpChangeResult] = Field(default_factory=list)
|
|
407
481
|
changed_fields: list[ChangedFieldResult] = Field(default_factory=list)
|
|
408
482
|
|
|
409
483
|
@property
|
|
410
484
|
def is_success(self) -> bool:
|
|
411
485
|
return all(
|
|
412
486
|
# MyPy fails to understand that ChangeFieldResult.message has the same structure as ChangeResult.message
|
|
413
|
-
isinstance(change.
|
|
487
|
+
isinstance(change.http_message, SuccessResponse) # type: ignore[attr-defined]
|
|
414
488
|
for change in itertools.chain(self.created, self.updated, self.deletions, self.changed_fields)
|
|
415
489
|
)
|
|
416
490
|
|
|
491
|
+
@property
|
|
492
|
+
def merged_updated(self) -> Sequence[ChangeResult]:
|
|
493
|
+
"""Merges the changed field into the updated changes."""
|
|
494
|
+
if not self.changed_fields:
|
|
495
|
+
return self.updated
|
|
496
|
+
changed_fields_by_id: dict[Hashable, list[ChangedFieldResult]] = defaultdict(list)
|
|
497
|
+
for changed_field in self.changed_fields:
|
|
498
|
+
changed_fields_by_id[changed_field.resource_id].append(changed_field)
|
|
499
|
+
merged_changes: list[ChangeResult] = []
|
|
500
|
+
for update in self.updated:
|
|
501
|
+
if update.change.resource_id not in changed_fields_by_id:
|
|
502
|
+
merged_changes.append(update)
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
field_changes = changed_fields_by_id[update.change.resource_id]
|
|
506
|
+
merged_change = update.change.model_copy(
|
|
507
|
+
update={"changes": update.change.changes + [fc.field_change for fc in field_changes]}
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# MyPy wants an annotation were we want this to be generic.
|
|
511
|
+
merged_result = MultiHTTPChangeResult( # type: ignore[var-annotated]
|
|
512
|
+
endpoint=update.endpoint,
|
|
513
|
+
change=merged_change,
|
|
514
|
+
http_messages=[update.http_message] + [fc.http_message for fc in field_changes],
|
|
515
|
+
)
|
|
516
|
+
merged_changes.append(merged_result)
|
|
517
|
+
|
|
518
|
+
return merged_changes
|
|
519
|
+
|
|
417
520
|
def as_recovery_plan(self) -> list[ResourceDeploymentPlan]:
|
|
418
521
|
"""Generate a recovery plan based on the applied changes."""
|
|
419
522
|
recovery_plan: dict[DataModelEndpoint, ResourceDeploymentPlan] = {}
|
|
420
523
|
for change_result in itertools.chain(self.created, self.updated, self.deletions):
|
|
421
|
-
if not isinstance(change_result.
|
|
524
|
+
if not isinstance(change_result.http_message, SuccessResponse):
|
|
422
525
|
continue # Skip failed changes.
|
|
423
526
|
change = change_result.change
|
|
424
527
|
if change.change_type == "create":
|
|
@@ -504,7 +607,7 @@ class DeploymentResult(BaseDeployObject):
|
|
|
504
607
|
|
|
505
608
|
@property
|
|
506
609
|
def is_dry_run(self) -> bool:
|
|
507
|
-
return self.
|
|
610
|
+
return self.responses is None
|
|
508
611
|
|
|
509
612
|
@property
|
|
510
613
|
def is_success(self) -> bool:
|
|
@@ -520,10 +623,10 @@ class DeploymentResult(BaseDeployObject):
|
|
|
520
623
|
if self.responses:
|
|
521
624
|
counts: dict[str, int] = defaultdict(int)
|
|
522
625
|
for change in itertools.chain(self.responses.created, self.responses.updated, self.responses.deletions):
|
|
523
|
-
suffix = type(change.
|
|
626
|
+
suffix = type(change.http_message).__name__.removesuffix("[TypeVar]").removesuffix("[~T_ResourceId]")
|
|
524
627
|
# For example: containers.created.successResponseItems
|
|
525
628
|
prefix = f"{change.endpoint}.{change.change.change_type}.{suffix}"
|
|
526
|
-
counts[prefix] += len(change.
|
|
629
|
+
counts[prefix] += len(change.http_message.ids)
|
|
527
630
|
|
|
528
631
|
output.update(counts)
|
|
529
632
|
return output
|
|
@@ -35,12 +35,13 @@ from .data_classes import (
|
|
|
35
35
|
AddedField,
|
|
36
36
|
AppliedChanges,
|
|
37
37
|
ChangedFieldResult,
|
|
38
|
-
ChangeResult,
|
|
39
38
|
ContainerDeploymentPlan,
|
|
40
39
|
DataModelEndpoint,
|
|
41
40
|
DeploymentResult,
|
|
42
41
|
FieldChange,
|
|
43
42
|
FieldChanges,
|
|
43
|
+
HTTPChangeResult,
|
|
44
|
+
NoOpChangeResult,
|
|
44
45
|
RemovedField,
|
|
45
46
|
ResourceChange,
|
|
46
47
|
ResourceDeploymentPlan,
|
|
@@ -289,24 +290,58 @@ class SchemaDeployer(OnSuccessResultProducer):
|
|
|
289
290
|
AppliedChanges: The result of applying the changes.
|
|
290
291
|
"""
|
|
291
292
|
applied_changes = AppliedChanges()
|
|
293
|
+
# If any HTTP request fails, the skip_message will be set and subsequent operations will be skipped
|
|
294
|
+
failure_message: str | None = None
|
|
292
295
|
for resource in reversed(plan):
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
if failure_message is None:
|
|
297
|
+
deletions = self._delete_items(resource)
|
|
298
|
+
applied_changes.deletions.extend(deletions)
|
|
299
|
+
if any(not deletion.is_success for deletion in deletions):
|
|
300
|
+
failure_message = f"Skipping due to {resource.endpoint} deletions failing."
|
|
301
|
+
else:
|
|
302
|
+
applied_changes.skipped.extend(
|
|
303
|
+
[
|
|
304
|
+
NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=failure_message)
|
|
305
|
+
for change in resource.to_delete
|
|
306
|
+
]
|
|
307
|
+
)
|
|
295
308
|
|
|
296
309
|
for resource in plan:
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
310
|
+
if failure_message is None:
|
|
311
|
+
if isinstance(resource, ContainerDeploymentPlan):
|
|
312
|
+
# Note that we continue to deploy even if removing constraints/indexes fail,
|
|
313
|
+
# as the creation/update of views and data models will still succeed.
|
|
314
|
+
applied_changes.changed_fields.extend(self._remove_container_constraints(resource))
|
|
315
|
+
applied_changes.changed_fields.extend(self._remove_container_indexes(resource))
|
|
316
|
+
|
|
317
|
+
creations, updated = self._upsert_items(resource)
|
|
318
|
+
applied_changes.created.extend(creations)
|
|
319
|
+
applied_changes.updated.extend(updated)
|
|
320
|
+
if any(not change.is_success for change in creations + updated):
|
|
321
|
+
failure_message = f"Skipping due to {resource.endpoint} upsert failing."
|
|
322
|
+
else:
|
|
323
|
+
applied_changes.skipped.extend(
|
|
324
|
+
[
|
|
325
|
+
NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=failure_message)
|
|
326
|
+
for change in resource.to_upsert
|
|
327
|
+
]
|
|
328
|
+
)
|
|
304
329
|
|
|
305
|
-
applied_changes.unchanged.extend(
|
|
306
|
-
|
|
330
|
+
applied_changes.unchanged.extend(
|
|
331
|
+
[
|
|
332
|
+
NoOpChangeResult(endpoint=resource.endpoint, change=change, reason="No changes detected.")
|
|
333
|
+
for change in resource.unchanged
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
applied_changes.skipped.extend(
|
|
337
|
+
[
|
|
338
|
+
NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=change.message or "Unknown")
|
|
339
|
+
for change in resource.skip
|
|
340
|
+
]
|
|
341
|
+
)
|
|
307
342
|
return applied_changes
|
|
308
343
|
|
|
309
|
-
def _delete_items(self, resource: ResourceDeploymentPlan) -> list[
|
|
344
|
+
def _delete_items(self, resource: ResourceDeploymentPlan) -> list[HTTPChangeResult]:
|
|
310
345
|
to_delete_by_id = {change.resource_id: change for change in resource.to_delete}
|
|
311
346
|
if not to_delete_by_id:
|
|
312
347
|
return []
|
|
@@ -319,7 +354,7 @@ class SchemaDeployer(OnSuccessResultProducer):
|
|
|
319
354
|
)
|
|
320
355
|
return self._process_resource_responses(responses, to_delete_by_id, resource.endpoint)
|
|
321
356
|
|
|
322
|
-
def _upsert_items(self, resource: ResourceDeploymentPlan) -> tuple[list[
|
|
357
|
+
def _upsert_items(self, resource: ResourceDeploymentPlan) -> tuple[list[HTTPChangeResult], list[HTTPChangeResult]]:
|
|
323
358
|
to_upsert = [
|
|
324
359
|
resource_change.new_value for resource_change in resource.to_upsert if resource_change.new_value is not None
|
|
325
360
|
]
|
|
@@ -375,14 +410,14 @@ class SchemaDeployer(OnSuccessResultProducer):
|
|
|
375
410
|
@classmethod
|
|
376
411
|
def _process_resource_responses(
|
|
377
412
|
cls, responses: APIResponse, change_by_id: dict[T_ResourceId, ResourceChange], endpoint: DataModelEndpoint
|
|
378
|
-
) -> list[
|
|
379
|
-
results: list[
|
|
413
|
+
) -> list[HTTPChangeResult]:
|
|
414
|
+
results: list[HTTPChangeResult] = []
|
|
380
415
|
for response in responses:
|
|
381
416
|
if isinstance(response, SuccessResponseItems | FailedResponseItems | FailedRequestItems):
|
|
382
417
|
for id in response.ids:
|
|
383
418
|
if id not in change_by_id:
|
|
384
419
|
continue
|
|
385
|
-
results.append(
|
|
420
|
+
results.append(HTTPChangeResult(change=change_by_id[id], http_message=response, endpoint=endpoint))
|
|
386
421
|
else:
|
|
387
422
|
# This should never happen as we do a ItemsRequest should always return ItemMessage responses
|
|
388
423
|
raise ValueError("Bug in Neat. Got an unexpected response type.")
|
|
@@ -398,7 +433,9 @@ class SchemaDeployer(OnSuccessResultProducer):
|
|
|
398
433
|
for id in response.ids:
|
|
399
434
|
if id not in change_by_id:
|
|
400
435
|
continue
|
|
401
|
-
results.append(
|
|
436
|
+
results.append(
|
|
437
|
+
ChangedFieldResult(resource_id=id, field_change=change_by_id[id], http_message=response)
|
|
438
|
+
)
|
|
402
439
|
else:
|
|
403
440
|
# This should never happen as we do a ItemsRequest should always return ItemMessage responses
|
|
404
441
|
raise RuntimeError("Bug in Neat. Got an unexpected response type.")
|
|
@@ -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
|