Skip to content

Commit b8dceee

Browse files
NouemanKHALclaudejanine-c
authored
feat(nutanix): add support for alerts tracking (DataDog#23538)
* feat(nutanix): alert lifecycle tracking with per-state metrics Replace the cursor-based alert collection with a reconciliation loop against the v4.0 unresolved-alerts API. Ship per-state lifecycle gauges and a default monitor template: - nutanix.alert.open — 1 while alert is unresolved + unacknowledged - nutanix.alert.acknowledged — 1 while alert is unresolved + acknowledged - nutanix.alert.resolved — 1 once when alert enters the resolved state State transitions emit explicit zeros to the previous state's metric so per-alert monitor cases recover cleanly when the alert leaves a state. Each metric carries ext_id for monitor grouping; the metric name itself encodes the state, so monitor queries don't need a tag filter. Lifecycle events (in addition to the metrics): - "Alert: <title>" — created (or re-opened from resolved) - "Alert acknowledged: <title>" — open -> acknowledged transition - "Alert reopened: <title>" — acknowledged -> open transition - "Alert Resolved: <title>" — resolution with resolvedTime / by / auto_resolved metadata Reconciliation is the source of truth each cycle: alerts in the API but not in the in-memory cache are new (emit open event); alerts in the cache but absent from the API are resolved or deleted (emit resolution event + .open or .acknowledged = 0, .resolved = 1); alerts in both have their cached metadata refreshed and ack-state transitions emit dedicated events. Stateless across check cycles in terms of persistence — agent restarts re-derive state from the API; the aggregation_key collapses any visible duplicate creation events on restart. Hardening: - on transient API failure, re-emit cached gauges before re-raising so per-alert monitors don't auto-resolve while the alert is still open. - pre-compute new/gone/still-tracked sets before mutating _open_alerts so loop ordering is safe. - v4.2 fallback removed; v4.0 endpoint with $filter=isResolved eq false is the only path. The pre-existing client-side filter remains as a safety net. Tags added to alert events and metrics: - ext_id, ntnx_alert_type, ntnx_alert_severity, ntnx_alert_status (events only — redundant on metrics where the name encodes state) - ntnx_originating_cluster_name, ntnx_alert_user_defined, ntnx_alert_service (Tier 1 — distinguish federated cluster, custom vs platform alerts, and Nutanix subsystem when present) - ntnx_cluster_name, ntnx_alert_classification, ntnx_alert_impact, ntnx_alert_auto_resolved (resolution events only), source-entity tags Default monitor template at assets/monitors/alerts.json combines nutanix.alert.open + nutanix.alert.acknowledged minus nutanix.alert.resolved to alert on any unresolved alert (clamped to non-negative). Auto-resolves on the resolved one-shot. Description notes the agent-restart re-broadcast trade-off. Test coverage: state transitions (open<->ack, ack->resolved from each prior state), filter-add edge case (treated as spurious resolution), deleted-alert (_get_alert returns None) graceful fallback, empty unresolved list cold-start, and per-tag assertions for the new Tier 1 tags. The four "complete output" alertType tests are parametrized. conftest mock has a _filter_after helper for the time-based fixture branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(nutanix): consolidate alert changelog entries Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(nutanix): revert version bump and shorten changelog Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * working monitor * feat(nutanix): heartbeat open alerts each cycle for event-based monitors Re-emit one alert event per tracked alert per check cycle so event-count monitors (last("Nm") > 0) stay firing while the alert is open. Transition cycles skip the heartbeat — the dedicated transition event already lands under the same aggregation_key. Resolved alerts are popped from _open_alerts before the heartbeat loop, so they don't get a duplicate heartbeat alongside their resolution event. Ship the default monitor template at assets/monitors/alerts.json with a real title and description. Tests cover the new heartbeat skip-list (transitions, resolutions, filter-exclusion), aggregation_key consistency across the full alert lifecycle, and the cached-gauges-no-events contract on transient API failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(nutanix): revert version bump and simplify monitor threshold Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(nutanix): address review feedback on alert lifecycle tracking - Drop the leaky self.alerts cache; _get_alert is now a one-shot fetch. - Warn loudly when the client-side isResolved safety net drops alerts. - Note that lastUpdatedTime is the closest signal for ack->open transitions. - Fix triple-space in monitor title, fix date format to YYYY-MM-DD. - Document the alert lifecycle and agent-restart behavior in the README. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nutanix): address alert tracking review feedback - Repoint NutanixCheck.alerts at _open_alerts so the public property matches what the activity monitor actually carries. - Restore a per-cycle dedup cache on _get_alert so _process_task does not issue N x M GETs for tasks referencing the same alerts. - Extract _reconcile_alerts into named helpers (new, resolved, transitioned, heartbeat, cached-gauge fallback) so the coordinator reads cleanly. - Namespace ext_id as ntnx_alert_ext_id on metrics, events, the monitor template, and metadata.csv to avoid colliding with other sources in the global ext_id tag. - Log a warning when the unresolved-alerts list call fails, and another when a gone alert cannot be fetched back from Prism Central. - Switch nutanix.alert.resolved from gauge to count so a resolved alert reopening with the same extId does not leave a stuck resolved=1 series. - Monitor template: query threshold > 0, use is_recovery consistently. - Update record_fixtures.py to use the production isResolved eq false filter so re-recorded fixtures match what the integration queries. - Document the alert lifecycle, agent restart behavior, and recommended metric monitor patterns in the README. - Add a test for the resolved to open lifecycle with the same extId. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nutanix): quote metadata.csv description containing a comma The nutanix.alert.resolved description contained an unquoted comma, splitting the row into 12 columns at parse time and breaking metadata validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(nutanix): correct alert reconciliation edge cases from review Two distinct correctness fixes to the alert lifecycle tracking path: - _get_alert no longer swallows every exception. A 404 still returns None (the alert was deleted upstream), but transient HTTP failures (5xx, network, timeout) propagate so they are not silently misclassified as deletions and emit degraded "Resolved" events. _emit_resolved_alerts catches the propagated HTTPError per-alert, restores tracking, and retries on the next cycle. Returned count now reflects actual emissions. - _reconcile_alerts now distinguishes alerts that truly left the unresolved-alerts API (resolved/deleted upstream) from alerts that are still open in Prism Central but no longer match the configured resource_filters. The latter are dropped from tracking silently with an info log; no resolution event or nutanix.alert.resolved increment is emitted, since the alert is not resolved. Test updates: - New test_get_alert_returns_none_on_404, test_get_alert_propagates_on_transient_http_error[500/502/503/504], and test_transient_alert_get_failure_preserves_tracking. - Existing test_alert_filter_excludes_tracked_alert_emits_spurious_resolution rewritten as test_alert_filter_excludes_tracked_alert_drops_without_resolution (pinned the prior bug; now asserts the correct behavior). - New test_resolution_event_still_fires_when_alert_truly_leaves_unresolved_api guards the gone_ids semantics regression. - conftest 404 mocks switched from bare Exception to requests.exceptions.HTTPError(response=...) so the 404 branch is actually exercised. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Apply suggestions from code review Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com> * fix(nutanix): shorten monitor description to under 300 chars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(nutanix): address review feedback on alert tracking - Guard the best-effort _get_alert call in _process_task with try/except HTTPError so a transient failure no longer aborts the task collection cycle, matching the guard already used in _emit_resolved_alerts. - Report currently-tracked open alerts in the check summary log instead of the per-cycle change count, which read 0 on quiet cycles. Only the INFO summary is affected; events and metrics are unchanged. - Drop the leading underscore from the SEVERITY_TO_ALERT_TYPE constant. - Move the fixture_alert helper to conftest.py and inline the complete-output parametrize cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(nutanix): stamp open alert event at observation time The open/heartbeat alert event was timestamped at the alert's creationTime. Event monitors window on occurrence time, so back-dated heartbeats never entered the recommended monitor's trailing 5m window: the monitor fired once at creation, then auto-recovered ~5m later and stayed OK regardless of the alert's real state in Prism Central. Stamp the open/heartbeat event at observation time so it lands in the monitor's rolling window. Recovery is now driven by heartbeats ceasing, so the monitor recovers ~5m after the alert actually resolves. Resolution and transition events keep their real timestamps; they are not counted by the status:open query and only feed the timeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(nutanix): drop fixed changelog fragments Keep only the added fragments on this branch; the fixed entries are removed at the maintainer's request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Janine Chan <64388808+janine-c@users.noreply.github.com>
1 parent 2babaf6 commit b8dceee

15 files changed

Lines changed: 1570 additions & 430 deletions

File tree

nutanix/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ Use the `collect_events`, `collect_alerts`, `collect_tasks`, and `collect_audits
6363

6464
**Note**: By default, only parent tasks are collected. Set `collect_subtasks: true` to include subtasks.
6565

66+
**Alert lifecycle.** Alerts are reconciled against Prism Central's unresolved-alerts API on every check cycle. While an alert is open, a heartbeat event (`msg_title: Alert: ...`) is emitted each cycle so event-based monitors stay firing; the first occurrence acts as the creation event. Transition events are emitted when an alert is acknowledged or reopened, and a resolution event is emitted when the alert is resolved or deleted. All events for the same alert share `aggregation_key=nutanix-alert-<extId>`, which collapses them into a single entry in the Events Explorer.
67+
68+
**Agent restart.** The integration is stateless across restarts. On startup it fetches all currently-unresolved alerts and re-emits a heartbeat event for each; `aggregation_key` collapses these duplicates with any prior events. State changes (acknowledgement, reopening) that happen during Agent downtime are not retroactively emitted as transition events. The next check cycle picks up the current state and proceeds normally.
69+
70+
**Building metric-based monitors for alerts.** The state of an alert is captured by `nutanix.alert.open` and `nutanix.alert.acknowledged` (gauges). `nutanix.alert.resolved` is a `count` of resolution transitions, not a state. Recommended patterns:
71+
72+
- Active alerts: `avg:nutanix.alert.open{*}.default_zero() > 0` by `ntnx_alert_ext_id`.
73+
- Active or acknowledged: `avg:nutanix.alert.open{*} + avg:nutanix.alert.acknowledged{*}` with `default_zero` and threshold `> 0`, grouped by `ntnx_alert_ext_id`.
74+
- Resolution rate: `sum:nutanix.alert.resolved{*}.as_count()` for dashboards or backlog monitors.
75+
76+
Because `nutanix.alert.resolved` is a count, do not subtract it from the open or acknowledged gauges; an alert can transition from resolved back to open with the same `ntnx_alert_ext_id`, and `.open` alone is the correct state signal.
77+
6678
### Service Checks
6779

6880
The integration does not emit any service checks.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"version": 2,
3+
"created_at": "2026-05-14",
4+
"last_updated_at": "2026-05-14",
5+
"title": "Nutanix alert is open in Prism Central",
6+
"description": "Tracks open Nutanix alerts from Prism Central. Fires when an alert is unresolved, auto-recovers on acknowledgement or resolution. Lifecycle events (created, acknowledged, reopened, resolved) are emitted to the Events Explorer under the same aggregation key.",
7+
"definition": {
8+
"id": 281752599,
9+
"name": "{{#is_alert}}OPEN{{/is_alert}}{{#is_recovery}}RESOLVED{{/is_recovery}} [Nutanix {{ntnx_alert_severity.name}}] [{{ntnx_alert_impact.name}}] [{{ntnx_originating_cluster_name.name}}] - {{ntnx_alert_ext_id.name}}",
10+
"type": "event-v2 alert",
11+
"query": "events(\"source:nutanix ntnx_type:alert ntnx_alert_status:open\").rollup(\"cardinality\", \"@aggregation_key\").by(\"@aggregation_key,ntnx_alert_severity,ntnx_alert_impact,ntnx_originating_cluster_name\").last(\"5m\") > 0",
12+
"message": "{{#is_alert}}A Nutanix alert has been raised or escalated.{{/is_alert}}\n {{#is_recovery}}The underlying Nutanix alert has recovered.{{/is_recovery}}\n\n **Alert:** `{{@aggregation_key.name}}`\n **Severity:** `{{ntnx_alert_severity.name}}`\n **Impact:** `{{ntnx_alert_impact.name}}`\n **Originating cluster:** `{{ntnx_originating_cluster_name.name}}`\n\n **Transition observed at:** {{last_triggered_at}}",
13+
"tags": [],
14+
"options": {
15+
"thresholds": {
16+
"critical": 0
17+
},
18+
"enable_logs_sample": false,
19+
"notify_audit": false,
20+
"on_missing_data": "default",
21+
"include_tags": true,
22+
"new_group_delay": 60,
23+
"renotify_interval": 0,
24+
"escalation_message": "",
25+
"silenced": {}
26+
},
27+
"priority": null,
28+
"restriction_policy": {
29+
"bindings": []
30+
}
31+
},
32+
"tags": [
33+
"integration:nutanix"
34+
]
35+
}

nutanix/changelog.d/23538.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Track each Nutanix alert through its lifecycle (open, acknowledged, resolved) with dedicated metrics, transition events, and a default monitor template.

nutanix/datadog_checks/nutanix/activity_monitor.py

Lines changed: 291 additions & 118 deletions
Large diffs are not rendered by default.

nutanix/datadog_checks/nutanix/check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def audits(self):
7272

7373
@property
7474
def alerts(self):
75-
return self.activity_monitor.alerts
75+
return self.activity_monitor._open_alerts
7676

7777
@property
7878
def tasks(self):

nutanix/manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@
6262
"Nutanix - Overview": "assets/dashboards/nutanix_overview.json",
6363
"Nutanix - Activity Monitoring": "assets/dashboards/nutanix_activity_monitoring.json"
6464
},
65-
"monitors": {},
65+
"monitors": {
66+
"Nutanix alert is open": "assets/monitors/alerts.json"
67+
},
6668
"saved_views": {}
6769
},
6870
"author": {

nutanix/metadata.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags
2+
nutanix.alert.acknowledged,gauge,,,,1 while a Nutanix alert is acknowledged but not yet resolved; 0 emitted once when leaving the acknowledged state. Tagged per-alert via ntnx_alert_ext_id.,0,nutanix,alert acknowledged,,ntnx_alert_ext_id
3+
nutanix.alert.open,gauge,,,,1 while a Nutanix alert is unresolved and unacknowledged; 0 emitted once when leaving the open state (acknowledged or resolved). Tagged per-alert via ntnx_alert_ext_id.,0,nutanix,alert open,,ntnx_alert_ext_id
4+
nutanix.alert.resolved,count,,,,"Incremented once each time a Nutanix alert is detected as resolved or deleted. Use for resolution-rate dashboards or backlog monitors; not a state metric, since alerts can transition from resolved back to open with the same ntnx_alert_ext_id. Use nutanix.alert.open for state.",0,nutanix,alert resolved,,ntnx_alert_ext_id
25
nutanix.api.rate_limited,count,,,,Count of HTTP 429 rate limit responses from the Prism Central API.,0,nutanix,rate_limited,,
36
nutanix.cluster.aggregate_hypervisor.memory_usage,gauge,,,,Total memory usage across all hypervisors in the cluster.,0,nutanix,usage,,
47
nutanix.cluster.controller.avg_io_latency,gauge,,,,Average I/O latency of the cluster storage controller.,0,nutanix,latency,,

nutanix/tests/conftest.py

Lines changed: 36 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@
55

66
import json
77
import os
8+
from datetime import datetime
89

910
import pytest
11+
from requests.exceptions import HTTPError
1012

1113
from datadog_checks.dev import docker_run, get_docker_hostname, get_here
1214
from datadog_checks.dev.conditions import CheckEndpoints
1315

16+
17+
def _filter_after(records, field, filter_param):
18+
"""Filter & sort records whose `field` ISO-8601 timestamp is after the value in `<field> gt …`."""
19+
threshold = datetime.fromisoformat(filter_param.split(f"{field} gt ")[-1].strip().replace("Z", "+00:00"))
20+
return sorted(
21+
(r for r in records if r.get(field) and datetime.fromisoformat(r[field].replace("Z", "+00:00")) > threshold),
22+
key=lambda r: datetime.fromisoformat(r[field].replace("Z", "+00:00")),
23+
)
24+
25+
1426
HERE = get_here()
1527
HOST = get_docker_hostname()
1628
DOCKER_DIR = os.path.join(HERE, 'docker')
@@ -43,6 +55,15 @@ def load_fixture_page(filename, page):
4355
return {"data": [], "metadata": {"totalAvailableResults": 0}}
4456

4557

58+
def fixture_alert(alert_type, **overrides):
59+
"""Load the first fixture alert with the given alertType and apply overrides."""
60+
for page in load_fixture('alerts.json'):
61+
for alert in page.get('data', []):
62+
if alert.get('alertType') == alert_type:
63+
return {**alert, **overrides}
64+
raise ValueError(f"No alert with alertType={alert_type} in fixture")
65+
66+
4667
# Test instance configurations
4768
INSTANCE = {
4869
"pc_ip": "10.0.0.197",
@@ -232,25 +253,8 @@ def mock_response(url, params=None, *args, **kwargs):
232253

233254
filter_param = params.get('$filter', '') if params else ''
234255
if 'creationTime gt' in filter_param:
235-
from datetime import datetime
236-
237-
filter_time_str = filter_param.split('creationTime gt ')[-1].strip()
238-
filter_time = datetime.fromisoformat(filter_time_str.replace('Z', '+00:00'))
239-
240-
filtered_data = []
241-
for event in response_data.get('data', []):
242-
event_time_str = event.get('creationTime', '')
243-
if event_time_str:
244-
event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00'))
245-
if event_time > filter_time:
246-
filtered_data.append(event)
247-
248-
filtered_data.sort(
249-
key=lambda t: datetime.fromisoformat(t.get('creationTime', '').replace('Z', '+00:00'))
250-
)
251-
252256
response_data = dict(response_data)
253-
response_data['data'] = filtered_data
257+
response_data['data'] = _filter_after(response_data.get('data', []), 'creationTime', filter_param)
254258

255259
mock_resp.json = mocker.Mock(return_value=response_data)
256260
return mock_resp
@@ -260,33 +264,16 @@ def mock_response(url, params=None, *args, **kwargs):
260264

261265
filter_param = params.get('$filter', '') if params else ''
262266
if 'creationTime gt' in filter_param:
263-
from datetime import datetime
264-
265-
filter_time_str = filter_param.split('creationTime gt ')[-1].strip()
266-
filter_time = datetime.fromisoformat(filter_time_str.replace('Z', '+00:00'))
267-
268-
filtered_data = []
269-
for audit in response_data.get('data', []):
270-
audit_time_str = audit.get('creationTime', '')
271-
if audit_time_str:
272-
audit_time = datetime.fromisoformat(audit_time_str.replace('Z', '+00:00'))
273-
if audit_time > filter_time:
274-
filtered_data.append(audit)
275-
276-
filtered_data.sort(
277-
key=lambda t: datetime.fromisoformat(t.get('creationTime', '').replace('Z', '+00:00'))
278-
)
279-
280267
response_data = dict(response_data)
281-
response_data['data'] = filtered_data
268+
response_data['data'] = _filter_after(response_data.get('data', []), 'creationTime', filter_param)
282269

283270
mock_resp.json = mocker.Mock(return_value=response_data)
284271
return mock_resp
285272

286273
# Individual alert fetch by ID (e.g. /alerts/{uuid})
287274
import re
288275

289-
alert_id_match = re.search(r'api/monitoring/v4\.\d/serviceability/alerts/([0-9a-f-]{36})', url)
276+
alert_id_match = re.search(r'api/monitoring/v4\.0/serviceability/alerts/([0-9a-f-]{36})', url)
290277
if alert_id_match:
291278
alert_ext_id = alert_id_match.group(1)
292279
all_alerts = load_fixture_page("alerts.json", 0).get('data', [])
@@ -295,33 +282,22 @@ def mock_response(url, params=None, *args, **kwargs):
295282
mock_resp.json = mocker.Mock(return_value={"data": alert_data})
296283
else:
297284
mock_resp.status_code = 404
298-
mock_resp.raise_for_status = mocker.Mock(side_effect=Exception("404 Not Found"))
285+
mock_resp.raise_for_status = mocker.Mock(side_effect=HTTPError(response=mock_resp))
299286
return mock_resp
300287

301-
if 'api/monitoring/v4.0/serviceability/alerts' in url or 'api/monitoring/v4.2/serviceability/alerts' in url:
288+
if 'api/monitoring/v4.0/serviceability/alerts' in url:
302289
response_data = load_fixture_page("alerts.json", page)
303290

304291
filter_param = params.get('$filter', '') if params else ''
305-
if 'creationTime gt' in filter_param:
306-
from datetime import datetime
307-
308-
filter_time_str = filter_param.split('creationTime gt ')[-1].strip()
309-
filter_time = datetime.fromisoformat(filter_time_str.replace('Z', '+00:00'))
310-
311-
filtered_data = []
312-
for alert in response_data.get('data', []):
313-
alert_time_str = alert.get('creationTime', '')
314-
if alert_time_str:
315-
alert_time = datetime.fromisoformat(alert_time_str.replace('Z', '+00:00'))
316-
if alert_time > filter_time:
317-
filtered_data.append(alert)
318-
319-
filtered_data.sort(
320-
key=lambda t: datetime.fromisoformat(t.get('creationTime', '').replace('Z', '+00:00'))
321-
)
322-
292+
if 'isResolved eq false' in filter_param:
323293
response_data = dict(response_data)
324-
response_data['data'] = filtered_data
294+
response_data['data'] = [a for a in response_data.get('data', []) if not a.get('isResolved')]
295+
elif 'lastUpdatedTime gt' in filter_param:
296+
response_data = dict(response_data)
297+
response_data['data'] = _filter_after(response_data.get('data', []), 'lastUpdatedTime', filter_param)
298+
elif 'creationTime gt' in filter_param:
299+
response_data = dict(response_data)
300+
response_data['data'] = _filter_after(response_data.get('data', []), 'creationTime', filter_param)
325301

326302
mock_resp.json = mocker.Mock(return_value=response_data)
327303
return mock_resp
@@ -330,32 +306,15 @@ def mock_response(url, params=None, *args, **kwargs):
330306

331307
filter_param = params.get('$filter', '') if params else ''
332308
if 'createdTime gt' in filter_param:
333-
from datetime import datetime
334-
335-
filter_time_str = filter_param.split('createdTime gt ')[-1].strip()
336-
filter_time = datetime.fromisoformat(filter_time_str.replace('Z', '+00:00'))
337-
338-
filtered_data = []
339-
for task in response_data.get('data', []):
340-
task_time_str = task.get('createdTime', '')
341-
if task_time_str:
342-
task_time = datetime.fromisoformat(task_time_str.replace('Z', '+00:00'))
343-
if task_time > filter_time:
344-
filtered_data.append(task)
345-
346-
filtered_data.sort(
347-
key=lambda t: datetime.fromisoformat(t.get('createdTime', '').replace('Z', '+00:00'))
348-
)
349-
350309
response_data = dict(response_data)
351-
response_data['data'] = filtered_data
310+
response_data['data'] = _filter_after(response_data.get('data', []), 'createdTime', filter_param)
352311

353312
mock_resp.json = mocker.Mock(return_value=response_data)
354313
return mock_resp
355314

356315
print(f"[MOCK ERROR] No matching endpoint for URL: {url}")
357316
mock_resp.status_code = 404
358-
mock_resp.raise_for_status = mocker.Mock(side_effect=Exception("404 Not Found"))
317+
mock_resp.raise_for_status = mocker.Mock(side_effect=HTTPError(response=mock_resp))
359318
return mock_resp
360319

361320
return mocker.patch('requests.Session.get', side_effect=mock_response)

nutanix/tests/docker/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ The Flask server mocks the following Nutanix Prism Central v4 APIs:
100100
- `GET /api/monitoring/v4.0/serviceability/events` - List events (paginated, time-filtered)
101101
- `GET /api/monitoring/v4.0/serviceability/audits` - List audits (paginated, time-filtered)
102102
- `GET /api/monitoring/v4.0/serviceability/alerts` - List alerts (paginated, time-filtered)
103-
- `GET /api/monitoring/v4.2/serviceability/alerts` - List alerts v4.2 (paginated, time-filtered)
104103
- `GET /api/prism/v4.0/config/tasks` - List tasks (paginated, time-filtered)
105104

106105
### Metadata APIs

nutanix/tests/docker/mock_server.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ def audits():
181181

182182

183183
@app.route('/api/monitoring/v4.0/serviceability/alerts')
184-
@app.route('/api/monitoring/v4.2/serviceability/alerts')
185184
def alerts():
186185
"""Alerts endpoint (paginated with time filtering)."""
187186
page = int(request.args.get('$page', 0))

0 commit comments

Comments
 (0)