FabrikFabrik

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:

ParamRequiredNotes
saved_query_idyesThe saved query to list snapshots for.
limitnoPage size (default 25, capped at 100).
offsetnoPagination offset (default 0).
datenoYYYY-MM-DD in the caller's timezone — filters to one day (used by heatmap cell clicks).
timezonenoIANA 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:

ParamRequiredNotes
saved_query_idyesThe saved query to chart.
yearnoCalendar year (defaults to the current year).
timezonenoIANA 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:

ParamRequiredNotes
saved_query_idyesThe saved query whose snapshots you're scanning.
dnyesThe exact DN string (e.g. uni/tn-prod/BD-web).
limitnoMax snapshots to consider (default 20, capped at 100).
from_datenoISO datetime. Only snapshots captured at or after this are scanned.
to_datenoISO 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:

ParamNotes
qSubstring filter (case-insensitive). Empty = return everything.
limitMax 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:

FieldTypeNotes
retention_policyenumdays | count | unlimited.
retention_daysintUsed when policy is days.
retention_countintNewest-N to keep per query, when policy is count.
max_snapshot_size_mbfloatCaptures over this are refused (default 10).
warn_large_snapshotsboolSurface a warning near the limit.
auto_cleanup_enabledboolLet the daily Celery Beat job purge expired snapshots.
store_duplicatesboolIf 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.