Time Machine
Capture, browse, compare, and annotate query result snapshots. Manage retention cleanup — endpoints under /api/time-machine/.
Time Machine captures the full row-set output of a saved query at a point in time. Snapshots are the source of truth for drift detection, change auditing, and historical comparisons. All endpoints under /api/time-machine/.
Snapshot IDs are UUIDs accepted as strings (uppercase or lowercase hex) — the older Django URL converters were strict about case, so the routes use <str:> instead of <uuid:>.
Capture
POST /api/time-machine/capture/
Store an already-executed query result as a snapshot. The endpoint does not run the query against APIC — the caller passes the raw result it already has. The service hashes the payload, runs deduplication and size checks, sets has_changes, and persists.
Request:
{
"result_data": { "imdata": [ /* raw APIC result */ ] },
"apic_connection_id": 3,
"apic_connection_name": "lab-aci",
"saved_query_id": 202, // null for ad-hoc/unsaved queries
"query_name": "acme — All Bridge Domains",
"class_name": "fvBD",
"query_structure": { /* flow JSON; used when saved_query_id is null */ },
"execution_time_ms": 412
}Response 200 (stored):
{
"success": true,
"snapshot_id": "…",
"result_count": 142,
"result_size_mb": 2.71,
"is_duplicate": false,
"has_changes": true
}Response 200 (duplicate suppressed): when the hash matches the previous snapshot and store_duplicates is off — nothing is written.
{
"success": true,
"skipped": true,
"reason": "duplicate",
"previous_snapshot_id": "…"
}Response 200 (rejected): success: false with a machine-readable error.
{ "success": false, "error": "snapshot_too_large", "size_mb": 23.4, "limit_mb": 10 }{ "success": false, "error": "apic_error_response",
"reason": "APIC returned an error or warning — snapshot not saved to prevent false drift alarms." }Capture always returns HTTP 200 — inspect success, skipped, and error to know what happened. There is no per-snapshot retention or annotation on capture; retention is a per-user setting (see below) and annotations are added separately via the annotate endpoint.
Browse
GET /api/time-machine/queries/
List saved queries that have at least one snapshot. Useful for the Time Machine landing page — which queries do we have history for?
Response 200:
{
"queries": [
{
"type": "saved",
"id": 202,
"name": "acme — All Bridge Domains",
"snapshot_count": 42,
"latest_execution": "…",
"version": "v1.3",
"enable_time_machine": true
}
]
}Only saved queries with Time Machine still enabled appear here.
GET /api/time-machine/snapshots/?saved_query_id=<id>
List snapshots for a specific query, most recent first. Paginated.
Query parameters:
| Param | Required | Notes |
|---|---|---|
saved_query_id | yes | The saved query to list snapshots for. |
limit | no | Page size (default 25, capped at 100). |
offset | no | Pagination offset (default 0). |
date | no | YYYY-MM-DD in the caller's timezone — filters to one day (used by heatmap cell clicks). |
timezone | no | IANA timezone for the date filter (default UTC). |
Response 200:
{
"total_count": 142,
"snapshots": [
{
"id": "…",
"query_name": "acme — All Bridge Domains",
"class_name": "fvBD",
"result_count": 142,
"result_size_bytes": 2840192,
"executed_at": "…",
"executed_by": "alice",
"apic_connection_name": "lab-aci",
"execution_time_ms": 412,
"result_hash": "…",
"is_duplicate": false,
"query_version": "v1.3",
"query_version_hash": "a1b2c3d4",
"execution_type": "scheduled",
"has_changes": true,
"annotation": null,
"label": null
}
]
}Row data isn't returned in list mode — fetch the detail endpoint for the full result_data.
GET /api/time-machine/snapshots/<id>/
Full snapshot. Returns the entire captured result set (can be large; clients should stream the response).
POST /api/time-machine/snapshots/<id>/annotate/
Add or replace a snapshot's annotation and/or label. At least one of the two fields is required.
Request: { "annotation": "After migration", "label": "post-CR-4821" }.
Compare
POST /api/time-machine/compare/
Diff two snapshots. Matching is always DN-based — there is no configurable key strategy. Objects without a dn attribute are skipped.
Request: both IDs are required. snapshot_from_id is the earlier side, snapshot_to_id the later one.
{
"snapshot_from_id": "<id>",
"snapshot_to_id": "<id>"
}Response 200:
{
"snapshot_from": { "id": "…", "executed_at": "…", "result_count": 127 },
"snapshot_to": { "id": "…", "executed_at": "…", "result_count": 129 },
"identical": false,
"diff": {
"added": [
{ "dn": "uni/tn-acme/BD-bd-dmz",
"object": { "fvBD": { "attributes": { "name": "bd-dmz", "arpFlood": "no" } } } }
],
"deleted": [
{ "dn": "uni/tn-acme/BD-bd-test",
"object": { "fvBD": { "attributes": { "name": "bd-test" } } } }
],
"modified": [
{
"dn": "uni/tn-acme/BD-bd-web",
"before": { "fvBD": { "attributes": { "arpFlood": "no", "unkMacUcastAct": "proxy" } } },
"after": { "fvBD": { "attributes": { "arpFlood": "yes", "unkMacUcastAct": "flood" } } },
"attribute_changes": [
{ "key": "arpFlood", "old": "no", "new": "yes" },
{ "key": "unkMacUcastAct", "old": "proxy", "new": "flood" }
]
}
],
"total_changes": 3
}
}attribute_changes is sorted alphabetically by key for deterministic rendering. When the two snapshots' hashes match, the service short-circuits to "identical": true with empty diff arrays.
Visualization
GET /api/time-machine/heatmap/?saved_query_id=<id>&year=<year>
Per-day snapshot counts for one calendar year, used to render the contribution-style heatmap. The service pre-fills every day of the year so the grid has no gaps.
Query parameters:
| Param | Required | Notes |
|---|---|---|
saved_query_id | yes | The saved query to chart. |
year | no | Calendar year (defaults to the current year). |
timezone | no | IANA timezone for assigning each snapshot to a local day (default UTC). |
Response 200: data is keyed by YYYY-MM-DD; each day carries a snapshot count and a has_changes flag.
{
"year": 2026,
"data": {
"2026-04-01": { "count": 24, "has_changes": false },
"2026-04-02": { "count": 24, "has_changes": true }
}
}GET /api/time-machine/timeline/
Powers the Track DN panel. Returns the evolution of every attribute on a single Distinguished Name across the snapshots of a saved query.
Query parameters:
| Param | Required | Notes |
|---|---|---|
saved_query_id | yes | The saved query whose snapshots you're scanning. |
dn | yes | The exact DN string (e.g. uni/tn-prod/BD-web). |
limit | no | Max snapshots to consider (default 20, capped at 100). |
from_date | no | ISO datetime. Only snapshots captured at or after this are scanned. |
to_date | no | ISO datetime. Only snapshots up to and including this are scanned. |
Response 200:
{
"dn": "uni/tn-prod/BD-web",
"saved_query_id": 202,
"snapshot_count": 12,
"points": [
{ "snapshot_id": "…", "executed_at": "…", "present": true,
"attributes": { "name": "web", "scope": "private", … } },
{ "snapshot_id": "…", "executed_at": "…", "present": false, "attributes": {} }
],
"attribute_evolution": [
{
"attribute": "scope",
"change_count": 1,
"is_stable": false,
"distinct_values": ["private", "shared"],
"values": [
{ "executed_at": "…", "snapshot_id": "…", "value": "private", "changed": false },
{ "executed_at": "…", "snapshot_id": "…", "value": "shared", "changed": true }
]
}
],
"tracked_attributes": ["arpFlood", "name", "scope", … ]
}points is in chronological order (oldest first); present: false entries
mean the DN was missing from that snapshot. The frontend Lifecycle bar uses
those gaps to render created / deleted markers.
GET /api/time-machine/saved-queries/<saved_query_id>/dns/
Distinct DNs present in the latest snapshot of a saved query. Powers the autocomplete in the Track DN picker.
Query parameters:
| Param | Notes |
|---|---|
q | Substring filter (case-insensitive). Empty = return everything. |
limit | Max rows returned (default 50, capped at 200). |
Response 200:
{
"dns": [
{ "dn": "uni/tn-prod", "className": "fvTenant" },
{ "dn": "uni/tn-mgmt", "className": "fvTenant" }
],
"count": 2
}This endpoint is intentionally exempt from the global rate-limiter — it bursts naturally as a user types into the autocomplete.
Settings
GET / PUT /api/time-machine/settings/
Per-user preferences. GET returns the current values (auto-creating a row from the global defaults on first access); PUT updates them.
Fields:
| Field | Type | Notes |
|---|---|---|
retention_policy | enum | days | count | unlimited. |
retention_days | int | Used when policy is days. |
retention_count | int | Newest-N to keep per query, when policy is count. |
max_snapshot_size_mb | float | Captures over this are refused (default 10). |
warn_large_snapshots | bool | Surface a warning near the limit. |
auto_cleanup_enabled | bool | Let the daily Celery Beat job purge expired snapshots. |
store_duplicates | bool | If true, persist even when the hash matches the previous snapshot (default false). |
Cleanup
Both cleanup endpoints are POST and accept an optional query_id in the body to scope the operation to a single saved query (omit it to apply across all of the user's queries).
POST /api/time-machine/cleanup/preview/
Dry-run the retention policy. Returns the snapshots that would be deleted; deletes nothing.
Request: { "query_id": 202 } (optional).
Response 200:
{
"count": 88,
"has_more": true,
"snapshots": [
{
"id": "…",
"query_name": "acme — All Bridge Domains",
"executed_at": "…",
"result_count": 142,
"size_bytes": 2840192
}
]
}snapshots is a capped preview list; count is the true total and has_more indicates the list was truncated.
POST /api/time-machine/cleanup/execute/
Run cleanup now. Normally handled by Celery Beat at 03:30 server time daily — this endpoint is for admin-triggered manual runs.
Response 200:
{ "message": "Cleanup executed successfully", "deleted_count": 88 }Cleanup is destructive — deleted snapshots are gone. Always hit /preview/ first, then /execute/. The action is logged in the audit trail either way.