diff --git a/CHANGELOG.md b/CHANGELOG.md index dc16915..1ea7fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to Fabrik are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.1] — 2026-05-26 + +### Fixed + +- Mouse-wheel scrolling now works inside the package picker that opens + from the Class Browser dialog. The popover was being rendered through + a portal into `document.body`, which sat outside the dialog's scroll + lock allow-list, so the wheel events were being swallowed before they + reached the list. The package picker now renders its popover inline + inside the dialog content so the dialog's scroll lock recognises it + as a child. + +### Changed + +- README rewritten. The structure and tone are tighter; the architecture + section now leans on the diagram rather than restating it in prose. + +### Removed + +- Three frontend stubs in `services/ai.ts` that posted to backend AI + endpoints which were never implemented (`/api/ai/generate/`, + `/generate/suggest/`, `/generate/feedback/`), plus the test cases + that exercised them. Nothing in production code was calling these. +- Stale "advisor" mentions in the `loader_v2` docstring; the feature + they referred to was never built. + ## [1.2.0] — 2026-05-26 ### Removed diff --git a/README.md b/README.md index c7e2a0e..d50a093 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # The fabric, finally legible. -**Visualize, Query, and Automate Your Cisco ACI Fabric — Without Writing API Calls.** +**Visualise, Query, and Automate Your Cisco ACI Fabric — Without Writing API Calls.** [![CI](https://img.shields.io/github/actions/workflow/status/onemli/fabrik/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/onemli/fabrik/actions/workflows/ci.yml) [![CodeQL](https://img.shields.io/github/actions/workflow/status/onemli/fabrik/codeql.yml?branch=main&style=flat-square&label=CodeQL&logo=github)](https://github.com/onemli/fabrik/actions/workflows/codeql.yml) @@ -18,13 +18,7 @@ [![Docker Frontend](https://img.shields.io/docker/pulls/onemli/fabrik-frontend?style=flat-square&label=frontend%20pulls&color=2563eb&logo=docker&logoColor=white)](https://hub.docker.com/r/onemli/fabrik-frontend) [![Docs](https://img.shields.io/badge/docs-fabrikops.com-7c3aed?style=flat-square)](https://docs.fabrikops.com/fabrik/) -[![Python](https://img.shields.io/badge/Python-3.13-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) -[![Django](https://img.shields.io/badge/Django-6.0-092E20?style=flat-square&logo=django&logoColor=white)](https://www.djangoproject.com/) -[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black)](https://react.dev/) -[![Neo4j](https://img.shields.io/badge/Neo4j-5-018BFF?style=flat-square&logo=neo4j&logoColor=white)](https://neo4j.com/) -[![Cisco ACI](https://img.shields.io/badge/Cisco%20ACI-5.2.x%20%7C%206.0.x%20%7C%206.1.x-1BA0D7?style=flat-square&logo=cisco&logoColor=white)](https://www.cisco.com/c/en/us/solutions/data-center-virtualization/application-centric-infrastructure/index.html) - -[**Documentation**](https://docs.fabrikops.com/fabrik/) · [**Quickstart**](https://docs.fabrikops.com/fabrik/getting-started/) · [**Releases**](https://github.com/onemli/fabrik/releases) · [**Discussions**](https://github.com/onemli/fabrik/discussions) · [**Report a bug**](https://github.com/onemli/fabrik/issues) +[**Docs**](https://docs.fabrikops.com/fabrik/) · [**Quickstart**](https://docs.fabrikops.com/fabrik/getting-started/) · [**Releases**](https://github.com/onemli/fabrik/releases) · [**Discussions**](https://github.com/onemli/fabrik/discussions) · [**Report a bug**](https://github.com/onemli/fabrik/issues)
@@ -34,91 +28,80 @@ --- -## What is Fabrik? +## Why this exists -Fabrik is a self-hosted operations platform for Cisco ACI that replaces the APIC API browser, ad-hoc Postman collections, and the "save this moquery in a notes file" workflow with a single canvas. +Anyone who has operated a Cisco ACI fabric long enough ends up with the same pile of tooling around APIC. -- **Visual query builder.** Drag classes, attach filters, run queries — Fabrik translates the diagram into APIC REST calls and returns structured results. -- **Configuration time machine.** Snapshot anything you can query. Diff snapshots, track a single DN over time, detect drift before it becomes an incident. -- **Automation orchestration.** Drive AWX/Ansible Tower job templates and workflows from automation requests. Structured table input with field validation backed by live APIC queries. AWX pulls the playbooks from your own Git repository (GitLab / GitHub / Gitea). -- **MIM browser.** Search 17,500+ ACI classes by name, label, description, DN pattern, or property — with AI-assisted suggestions validated against the live MIM. +A folder full of moquery commands. +A Postman collection nobody maintains anymore. +Python scripts that manually build /api/mo/... URLs. +Screenshots from Visore tabs saved “just in case.” +Spreadsheets for bulk changes. +Shell history full of copied queries. -> Full documentation, screenshots, and tutorials live at **[docs.fabrikops.com](https://docs.fabrikops.com/fabrik/)**. +The reality is that most ACI operational workflows still live outside APIC itself. ---- +Fabrik brings those workflows into a single place. + +## What it does + +- **Visual query builder.** Draw a query on a React Flow canvas: drag classes, attach filters, connect parents to children. Fabrik turns the diagram into the right APIC REST call and shows the result as a table you can export. +- **A library you can share.** Save the queries that work, tag them, give them to your team. New hires stop reinventing the BGP-peer query on day three. +- **Time Machine.** Snapshot anything you can query. Diff two snapshots side by side, or follow one DN across weeks. Useful when someone asks "did this BD always have this subnet?". +- **AWX automation, with guardrails.** Build a request from a structured table (each column validated against live APIC data), then hand it to AWX as a job or workflow. AWX pulls playbooks from your own Git (GitLab / GitHub / Gitea); Fabrik never touches your repo. +- **MIM browser.** The full ACI Managed Information Model (17,500+ classes), searchable by name, label, description, DN pattern, or property. Optional AI assist suggests classes from a plain-English description and validates every suggestion against the live MIM before showing it. -## Quickstart +The pieces are designed to work together. A query you save in the Library can become the input source for an automation request. A Time Machine snapshot can be the basis for a diff that triggers an alert. A class lookup on the MIM browser is one click from a new query on the canvas. -> **Requirements:** Docker 24+ · Docker Compose v2. Sizing depends on fabric size and Time Machine retention — see the [deployment guide](https://docs.fabrikops.com/fabrik/deployment) for current recommendations. +## Quick start + +You need Docker 24+ and Docker Compose v2. That's it. ```bash mkdir fabrik && cd fabrik curl -fLo docker-compose.yml https://github.com/onemli/fabrik/releases/latest/download/docker-compose.release.yml curl -fLo .env.example https://github.com/onemli/fabrik/releases/latest/download/.env.example cp .env.example .env +``` + +Open `.env` and fill in the four things you have to fill in: -# Edit .env — at minimum set: -# DJANGO_SECRET_KEY, ENCRYPTION_KEY -# POSTGRES_PASSWORD (and update the password inside DATABASE_URL!) -# NEO4J_PASSWORD -# ALLOWED_HOSTS, CORS_ALLOWED_ORIGINS +- `DJANGO_SECRET_KEY` and `ENCRYPTION_KEY`: generate fresh values, don't leave the placeholders +- `POSTGRES_PASSWORD`, plus the same password inside `DATABASE_URL` on the line below +- `NEO4J_PASSWORD` +- `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS`: the hostname you'll actually reach Fabrik at +Then bring it up: + +```bash docker compose pull docker compose up -d docker compose exec backend python manage.py createsuperuser ``` -> **Heads up:** `DATABASE_URL` embeds the same password as `POSTGRES_PASSWORD`. Django reads the URL form, not the individual fields — if the two drift apart you'll get `password authentication failed` on first boot. Change them together. - -Open **`http://`** (or whatever hostname / reverse-proxy URL you've put in front of the frontend container — the frontend serves on port 80 by default), sign in, then go to **Settings → MIM Management** to import the ACI schema (~25 minutes, runs in the background). +Open `http://` (port 80 by default), sign in with the superuser, and head to **Settings → MIM Management** to import the ACI schema for your APIC version. The import runs in the background and takes around 25 minutes the first time. Once it's done, the canvas knows every class in the fabric. -That's it. Detailed walkthrough, production deployment, reverse proxy, backups, and upgrades on **[docs.fabrikops.com](https://docs.fabrikops.com/fabrik/)**. +Everything beyond this (TLS, reverse proxy, backup, upgrade paths, sizing for larger fabrics) is on **[docs.fabrikops.com](https://docs.fabrikops.com/fabrik/)**. ---- - -## Architecture +## Under the hood

Fabrik architecture: operator → web tier (React + Django) → workers (Celery) → stateful services (Neo4j, PostgreSQL, Redis) → external systems (APIC, AWX, Git SCM)

- - - -> Solid arrows are synchronous calls; dashed arrows are asynchronous events. - -### How a request flows - -A user signs into the React frontend, which talks to the Django backend over JSON + JWT. Synchronous reads — class lookups, query validation, MIM browsing — return on the request thread. Anything long-running (a query against APIC, a snapshot capture, an AWX automation) is handed to **Celery** through Redis, runs in a worker, and streams progress back to the browser over a Redis-backed WebSocket channel layer. AWX job status comes back the other way: AWX posts webhook events to a Django endpoint, which updates Postgres and broadcasts progress over the same WebSocket. A 30-second Celery sync poll backs the webhook up so status stays correct even if a webhook is missed. The user never blocks on a slow API call. - -### What lives where - -| Service | Role | +| | | |---|---| -| **Frontend** | React 19 + Vite. Holds the React Flow canvas, query builder state (Zustand), and TanStack Query for server cache. | -| **Backend** | Django 6 + DRF served by Daphne (ASGI). REST endpoints, WebSocket consumers, RBAC, audit logging, APIC client with automatic token refresh. | -| **Neo4j** | The ACI Managed Information Model as a graph: 17,500+ classes, containment, `Rs*` references, properties. Powers query validation and the MIM browser. | -| **PostgreSQL** | Saved queries, snapshots (Time Machine), users, AWX automations, the immutable audit trail. | -| **Redis** | Backend cache, Celery broker, and Channels layer for WebSocket fan-out. | -| **Celery worker + beat** | Background query execution, scheduled tasks (every minute), AWX job polling, daily Time Machine retention sweep. | -| **AWX / Tower** *(optional)* | Runs Ansible playbooks. Only needed if you use the automation feature. | -| **Git SCM** *(optional)* | Playbook source for AWX. Fabrik launches a job; AWX pulls the latest playbook from GitLab / GitHub / Gitea before running it. Fabrik itself never writes to the repo. | +| Frontend | React 19, Vite, React Flow, Zustand, TanStack Query, Tailwind | +| Backend | Django 6, DRF, Channels, Daphne ASGI | +| Workers | Celery worker + beat | +| Graph | Neo4j 5.26 (ACI MIM only) | +| Relational | PostgreSQL 17 | +| Cache, broker, channel layer | Redis 8 | +| Optional | AWX / Ansible Tower for automation; LDAP for SSO; SMTP for notifications | -### Boundaries +## Status -The full stack runs from a single `docker compose up`. **APIC** is the only required out-of-stack dependency; **AWX** is optional and only used when the automation feature is enabled. AWX in turn pulls playbooks from a Git repository you operate — Fabrik never writes to that repo, it just hands AWX a job spec. No Kubernetes, no managed services, no telemetry, no phone-home. - ---- - -## Project status - -Fabrik is in **active development** with a stable core in production use. Bug reports and feature requests are welcome via GitHub Issues and Discussions. +Fabrik is in active development with a stable core that runs in production. Breaking changes are flagged in the changelog and called out in release notes. Bug reports and ideas are welcome. | | | |---|---| @@ -127,10 +110,8 @@ Fabrik is in **active development** with a stable core in production use. Bug re | **Security disclosure** | [SECURITY.md](./SECURITY.md) | | **Release history** | [CHANGELOG.md](./CHANGELOG.md) | ---- - ## License -Released under the [Apache License 2.0](./LICENSE). +Apache License 2.0. See [LICENSE](./LICENSE). Cisco, ACI, APIC, and AWX are trademarks of their respective owners. Fabrik is an independent open-source project and is not affiliated with or endorsed by Cisco Systems. diff --git a/backend/awx/services/execution_engine.py b/backend/awx/services/execution_engine.py index 2e3d2e5..fd7a5f9 100644 --- a/backend/awx/services/execution_engine.py +++ b/backend/awx/services/execution_engine.py @@ -256,7 +256,7 @@ def execute_bulk( ) # Platform info - extra_vars['fabrik_platform_version'] = getattr(settings, 'FABRIK_VERSION', '1.2.0') + extra_vars['fabrik_platform_version'] = getattr(settings, 'FABRIK_VERSION', '1.2.1') # Add any additional variables from template if template.variable_mappings: diff --git a/backend/dashboard/views.py b/backend/dashboard/views.py index 77aea74..117dad2 100644 --- a/backend/dashboard/views.py +++ b/backend/dashboard/views.py @@ -29,7 +29,7 @@ def platform_info(request): return Response( { 'demo_mode': getattr(settings, 'DEMO_MODE', False), - 'version': os.getenv('FABRIK_VERSION', '1.2.0'), + 'version': os.getenv('FABRIK_VERSION', '1.2.1'), 'ldap_enabled': getattr(settings, 'LDAP_ENABLED', False), 'registration_enabled': getattr(settings, 'FABRIK_ALLOW_PUBLIC_REGISTRATION', False), } diff --git a/backend/fabrik/settings.py b/backend/fabrik/settings.py index 88e4750..ccd92ba 100644 --- a/backend/fabrik/settings.py +++ b/backend/fabrik/settings.py @@ -212,7 +212,7 @@ # OpenAPI / Swagger (drf-spectacular) SPECTACULAR_SETTINGS = { 'TITLE': 'FABRIK API', - 'VERSION': '1.2.0', + 'VERSION': '1.2.1', 'DESCRIPTION': 'Visualise, Query, and Automate Your Cisco ACI Fabric — Without Writing API Calls.', 'SCHEMA_PATH_PREFIX': '/api/', 'SERVE_INCLUDE_SCHEMA': False, @@ -503,7 +503,7 @@ # ============================================================================= # Platform metadata (used in audit trails and extra_vars) -FABRIK_VERSION = os.getenv('FABRIK_VERSION', '1.2.0') +FABRIK_VERSION = os.getenv('FABRIK_VERSION', '1.2.1') FABRIK_BASE_URL = os.getenv('FABRIK_BASE_URL', 'http://localhost:3000') # Feature Flags diff --git a/backend/mim_registry/services/loader_v2.py b/backend/mim_registry/services/loader_v2.py index f2d4718..4def3b1 100644 --- a/backend/mim_registry/services/loader_v2.py +++ b/backend/mim_registry/services/loader_v2.py @@ -6,7 +6,7 @@ 1. Input format: devnet bundles use keys like "fv:Tenant" (package-colon-class). v2 normalizes to "fvTenant" internally so Neo4j stores, query builder output, - frontend references, and advisor lookups all stay on the existing convention. + and frontend references all stay on the existing convention. The original package-qualified form is preserved as Class.qualifiedName (e.g. "fv:Tenant") for reverse lookup and to stay source-agnostic — any future devnet/cobra/XML schema source provides the same representation. @@ -29,7 +29,7 @@ 5. New label: :EnumValue (one per enum/bitmask constant). -Preserved from v1 (zero breakage for existing query builder / advisor code): +Preserved from v1 (zero breakage for existing query builder code): - (:Class {className}) — className is still the normalized form - (:Class)-[:HAS_PROPERTY]->(:Property) - (:Class)-[:CONTAINS]->(:Class) @@ -443,7 +443,7 @@ def _prepare_single( { 'className': class_name, 'propName': prop_name, - # Semantic metadata (new in v2 — the key win for advisor/UI) + # Semantic metadata (new in v2 — the key win for UI) 'label': pdata.get('label', ''), 'comment': pdata.get('comment', []) or [], 'baseType': pdata.get('baseType', ''), diff --git a/frontend/package.json b/frontend/package.json index 9c495e1..d81a87b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "fabrik-frontend", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/assets/architecture.mmd b/frontend/src/assets/architecture.mmd index 9101e76..e37143a 100644 --- a/frontend/src/assets/architecture.mmd +++ b/frontend/src/assets/architecture.mmd @@ -22,7 +22,6 @@ flowchart LR NEO[(Neo4j
MIM graph)] PG[(PostgreSQL
state · snapshots · audit)] REDIS[(Redis
cache · Celery broker · channel layer)] - MQ[(RabbitMQ
AWX event bus)] end User -->|HTTPS| FE @@ -43,8 +42,8 @@ flowchart LR BE -->|class / mo queries| APIC WORKER -->|launch jobs| AWX - AWX -.->|webhook events| MQ - MQ -.->|consume| WORKER + AWX -.->|webhook events| BE + BEAT -.->|30s status poll| AWX AWX -.->|pull playbooks| SCM classDef ext stroke:#64748b,stroke-width:1.5px @@ -54,7 +53,7 @@ flowchart LR classDef user stroke:#6b7280,stroke-width:1.5px class APIC,AWX,SCM ext - class NEO,PG,REDIS,MQ data + class NEO,PG,REDIS data class WORKER,BEAT worker class FE,BE,WS web class User user diff --git a/frontend/src/assets/architecture.svg b/frontend/src/assets/architecture.svg new file mode 100644 index 0000000..aef2f30 --- /dev/null +++ b/frontend/src/assets/architecture.svg @@ -0,0 +1 @@ +

Stateful services

Workers

Web tier

External systems

HTTPS

REST + JWT

live progress

Cypher

ORM

cache hits

enqueue tasks

deliver tasks

schedule

results

progress events

fan-out

class / mo queries

launch jobs

webhook events

30s status poll

pull playbooks

Operator

APIC REST

AWX / Tower
optional

Git SCM
playbook source
optional

Frontend
React 19 · Vite · Zustand

Backend
Django · DRF · Daphne ASGI

WebSocket
Channels

Celery worker
queries · automations

Celery beat
scheduler

Neo4j
MIM graph

PostgreSQL
state · snapshots · audit

Redis
cache · Celery broker · channel layer

\ No newline at end of file diff --git a/frontend/src/components/ui/PackageCombobox.tsx b/frontend/src/components/ui/PackageCombobox.tsx index ad23fed..bc1c370 100644 --- a/frontend/src/components/ui/PackageCombobox.tsx +++ b/frontend/src/components/ui/PackageCombobox.tsx @@ -101,7 +101,7 @@ export function PackageCombobox({ )} - + diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 821a32f..f44e8f2 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -7,11 +7,22 @@ const Popover = PopoverPrimitive.Root const PopoverTrigger = PopoverPrimitive.Trigger +type PopoverContentProps = React.ComponentPropsWithoutRef & { + /** + * Opt out of the default Radix Portal that renders into document.body. + * When this Popover lives inside a Radix Dialog, the Dialog's RemoveScroll + * blocks wheel events on anything outside its content subtree — including + * portaled popover bodies. Rendering inline keeps the popover inside the + * Dialog's whitelist so mouse-wheel scrolling works. + */ + withoutPortal?: boolean +} + const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - + PopoverContentProps +>(({ className, align = "center", sideOffset = 4, withoutPortal = false, ...props }, ref) => { + const content = ( - -)) + ) + + return withoutPortal ? content : {content} +}) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/src/services/__tests__/ai.test.ts b/frontend/src/services/__tests__/ai.test.ts index c9b85e4..e1c9573 100644 --- a/frontend/src/services/__tests__/ai.test.ts +++ b/frontend/src/services/__tests__/ai.test.ts @@ -92,47 +92,6 @@ describe('aiService', () => { }) }) - describe('generateQuery', () => { - it('sends natural language query', async () => { - mockApi.post.mockResolvedValueOnce({ - data: { - success: true, - query: { nodes: [{ id: 'n1', type: 'classNode' }], edges: [] }, - metadata: { confidence_score: 0.85 }, - }, - }) - - const result = await aiService.generateQuery('show all tenants') - expect(result.success).toBe(true) - expect(result.query?.nodes).toHaveLength(1) - }) - }) - - describe('getSuggestions', () => { - it('returns suggestions', async () => { - mockApi.post.mockResolvedValueOnce({ - data: { suggestions: ['show tenants', 'show BDs'] }, - }) - - const result = await aiService.getSuggestions('show') - expect(result.suggestions).toHaveLength(2) - }) - }) - - describe('submitFeedback', () => { - it('sends feedback with log id', async () => { - mockApi.post.mockResolvedValueOnce({ data: { success: true } }) - - const result = await aiService.submitFeedback(42, true, 'Great query!') - expect(result.success).toBe(true) - expect(mockApi.post).toHaveBeenCalledWith('/api/ai/generate/feedback/', { - log_id: 42, - accepted: true, - feedback: 'Great query!', - }) - }) - }) - // Provider management (BYOK) describe('getAvailableProviders', () => { diff --git a/frontend/src/services/ai.ts b/frontend/src/services/ai.ts index c51e83d..5446928 100644 --- a/frontend/src/services/ai.ts +++ b/frontend/src/services/ai.ts @@ -41,50 +41,6 @@ export interface AIModel { modified_at: string } -export interface AIGeneratedNode { - id: string - type: string - position: { x: number; y: number } - data: { - className: string - label?: string - scope?: string - classInfo?: { label?: string } - filters?: AIGeneratedFilter[] - [key: string]: unknown - } -} - -export interface AIGeneratedFilter { - property: string - operator: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le' | 'wcard' | 'contains' - value: string -} - -export interface AIGeneratedEdge { - id: string - source: string - target: string -} - -export interface AIGenerateResponse { - success: boolean - query?: { - nodes: AIGeneratedNode[] - edges: AIGeneratedEdge[] - } - validation?: { - is_valid: boolean - errors: string[] - warnings: string[] - } - metadata?: { - confidence_score: number - log_id: number - } - error?: string -} - export interface AITestConnectionResponse { success: boolean message: string @@ -92,10 +48,6 @@ export interface AITestConnectionResponse { ollama_url?: string } -export interface AISuggestResponse { - suggestions: string[] -} - // Provider types export interface AIProvider { id: string @@ -178,34 +130,6 @@ export const aiService = { return response.data }, - /** - * Generate query from natural language - */ - async generateQuery(query: string): Promise { - const response = await api.post('/api/ai/generate/', { query }) - return response.data - }, - - /** - * Get query suggestions - */ - async getSuggestions(prompt: string): Promise { - const response = await api.post('/api/ai/generate/suggest/', { prompt }) - return response.data - }, - - /** - * Submit feedback for a generated query - */ - async submitFeedback(logId: number, accepted: boolean, feedback?: string): Promise<{ success: boolean }> { - const response = await api.post('/api/ai/generate/feedback/', { - log_id: logId, - accepted, - feedback - }) - return response.data - }, - // ============================================ // Provider Management (BYOK - Bring Your Own Key) // ============================================ diff --git a/pyproject.toml b/pyproject.toml index fc5dab4..8612e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fabrik" -version = "1.2.0" +version = "1.2.1" description = "Visualise, Query, and Automate Your Cisco ACI Fabric — Without Writing API Calls." requires-python = ">=3.12"