mcp-server-appwrite 2.1.2__tar.gz → 2.2.0__tar.gz
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.
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/PKG-INFO +1 -1
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/apps.md +26 -6
- mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/executions.py +188 -0
- mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/files.py +210 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/resource.py +2 -1
- mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/usage.py +188 -0
- mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/users.py +168 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/views.py +107 -11
- mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/assets/view.html +1544 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/data/docs_index.npz +1 -1
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/server.py +4 -1
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/support.py +355 -13
- mcp_server_appwrite-2.2.0/tests/e2e/test_apps.py +779 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_tools.py +21 -3
- mcp_server_appwrite-2.1.2/src/mcp_server_appwrite/assets/view.html +0 -810
- mcp_server_appwrite-2.1.2/tests/e2e/test_apps.py +0 -380
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.dockerignore +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.env.example +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/ci.yml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/production.yml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/publish.yml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/staging.yml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/update-docs-index.yml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.gitignore +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.python-version +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/AGENTS.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/CLAUDE.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/Dockerfile +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/LICENSE +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/README.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/compose.yaml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/appwrite-mcp-flow.png +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/appwrite-mcp-flow.svg +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/authentication.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/development.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/documentation-search.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/events.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/flags.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/claude-desktop-local-mcp-servers.png +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/codex-mcp.png +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/cursor-mcp.png +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/self-hosted.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/tool-surface.md +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/glama.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/pyproject.toml +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/build_docs_index.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/docs_release_notes.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/render_server_json.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/server.template.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/__init__.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/__main__.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/__init__.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/protocol.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/__init__.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/favicon.svg +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/instructions.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/auth.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/catalog_policy.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/constants.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/context.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_index.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_search.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_source.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/error_classification.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/error_monitoring.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/__init__.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/bus.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/catalog.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/delivery.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/egress.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/envelope.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/errors.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/ingress.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/projection.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/protocol.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/streams.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/subscriptions.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/webhooks.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/flags.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/http_app.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/instructions.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/legacy.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/operator.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/service.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/telemetry.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/tool_manager.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_duplicate.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_failed.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_ready.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_site_ready.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_async_failed.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_async_waiting.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_sync_failed.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/file_created.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/function_activation.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/row_created.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/user_created.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_ingress.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_protocol.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_stream.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_subscribe.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_stdio.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/support.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_avatars.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_console_sdk_oauth.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_docs_index.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_docs_source.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_functions.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_locale.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_messaging.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_oauth_discovery.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_operator.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_sites.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_storage.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_tables_db.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_teams.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_users.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/test_all.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/fixtures/jwe-v1.json +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_auth.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_context.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_docs.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_docs_search.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_error_classification.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_error_monitoring.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_delivery.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_egress.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_envelope.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_protocol.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_http_app.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_instructions.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_operator.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_release_metadata.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_server.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_service.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_telemetry.py +0 -0
- {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-server-appwrite
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Build and deploy application backends. Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -9,17 +9,34 @@ interactive view inside the chat. This server offers one view,
|
|
|
9
9
|
|
|
10
10
|
| Name | Seen by | What it does |
|
|
11
11
|
| --- | --- | --- |
|
|
12
|
-
| `appwrite_show` | model and view | `view=table`: a page of a table's rows, newest first, with `cursor` paging. `view=deployment`: a Function or Site deployment (the latest unless `deployment_id` is given) with the last 200 build log lines. Read-only. |
|
|
13
|
-
| `appwrite_view_write` | view only (`visibility: ["app"]`) | Runs one of `tables_db_update_row`, `functions_update_deployment_status`, `sites_update_deployment_status`, only with `confirm_write=true`. |
|
|
14
|
-
| `ui://appwrite/view` | host | One HTML document (`assets/view.html`) that renders
|
|
12
|
+
| `appwrite_show` | model and view | `view=table`: a page of a table's rows, newest first, with `cursor` paging. `view=deployment`: a Function or Site deployment (the latest unless `deployment_id` is given) with the last 200 build log lines. `view=executions`: a Function's executions, newest first, optionally one `status`, with `cursor` paging; `execution_id` also opens one with its request and response headers, status code, duration, and the last 200 lines of logs and of errors. `view=files`: a Storage bucket's files, newest first, with `search` (file name) and `cursor` paging; `file_id` also opens one. `view=users`: the project's Auth users, newest first, with `search` (name, email, phone or ID) and `cursor` paging; `user_id` also opens one with their ten newest sessions. `view=usage`: a project's requests, bandwidth (in and out) and executions per interval over `range` (`24h` hourly, `7d` or `30d` daily), with totals and the newest snapshot of its users, resource counts and storage. Read-only. |
|
|
13
|
+
| `appwrite_view_write` | view only (`visibility: ["app"]`) | Runs one of `tables_db_update_row`, `functions_update_deployment_status`, `sites_update_deployment_status`, `users_update_status`, `users_update_labels`, `users_update_email_verification`, only with `confirm_write=true`. |
|
|
14
|
+
| `ui://appwrite/view` | host | One HTML document (`assets/view.html`) that renders every view. |
|
|
15
15
|
|
|
16
16
|
`appwrite_show` returns a text summary in `content`, which is what the model and
|
|
17
17
|
hosts without MCP Apps see, and the data in `structuredContent` for the view.
|
|
18
18
|
The view calls `appwrite_show` again to page, refresh and follow a build (every
|
|
19
|
-
3 seconds until it is ready, failed or canceled)
|
|
19
|
+
3 seconds until it is ready, failed or canceled), and to follow executions
|
|
20
|
+
while any on the page, or the opened one, is waiting or processing. Appwrite
|
|
21
|
+
keeps an execution's response body only for the synchronous call that ran it,
|
|
22
|
+
so the view shows it only when present. An edited row or a cancelled
|
|
20
23
|
build goes through a confirmation dialog in the view, and only then through
|
|
21
|
-
`appwrite_view_write
|
|
22
|
-
|
|
24
|
+
`appwrite_view_write`, as do blocking or unblocking a user, marking their
|
|
25
|
+
email verified or not, and changing their labels. A successful change and a
|
|
26
|
+
finished build are passed to the model with `ui/update-model-context` when the
|
|
27
|
+
host supports it.
|
|
28
|
+
|
|
29
|
+
The files view has no network access, so it cannot load a file's preview
|
|
30
|
+
URL. It calls `appwrite_show` again with `thumbnails: true`, and the server
|
|
31
|
+
renders the page's images through Appwrite's image transformations
|
|
32
|
+
(`storage_get_file_preview`, eight at a time) and returns them as data URIs.
|
|
33
|
+
The model's own call leaves them out, since some hosts put
|
|
34
|
+
`structuredContent` in the model's context.
|
|
35
|
+
|
|
36
|
+
The usage view reads the Usage API (`usage_list_events` and
|
|
37
|
+
`usage_list_gauges`). Appwrite keeps usage history for as long as the plan
|
|
38
|
+
allows; a range past it returns Appwrite's own error, which the view shows in
|
|
39
|
+
place of the chart it was switching to.
|
|
23
40
|
|
|
24
41
|
## Who is offered the view
|
|
25
42
|
|
|
@@ -42,6 +59,9 @@ gated, because the view's own calls reach the server through the host.
|
|
|
42
59
|
because a script injected through that data could call `appwrite_view_write`.
|
|
43
60
|
The allowlist bounds what such a script could do, and `confirm_write` is
|
|
44
61
|
required as for `appwrite_call_write_tool`.
|
|
62
|
+
- User documents carry the password hash, prefs and messaging targets, and
|
|
63
|
+
sessions carry OAuth provider tokens. The users view, and the user writes'
|
|
64
|
+
results, carry only the fields the view shows.
|
|
45
65
|
|
|
46
66
|
## Trying it in a real host
|
|
47
67
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``view=executions``: a Function's executions, and one execution in full.
|
|
2
|
+
|
|
3
|
+
The list is a page of executions, newest first, optionally narrowed to one
|
|
4
|
+
status. With ``execution_id`` the payload also carries that execution's
|
|
5
|
+
request, response headers, logs and errors (Appwrite keeps a response body
|
|
6
|
+
only for the synchronous call that ran it), so "why did the last run fail?" opens on
|
|
7
|
+
the answer. The view polls while an execution on the page or the opened one
|
|
8
|
+
is still waiting or running.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from appwrite_console.query import Query
|
|
17
|
+
|
|
18
|
+
from ..constants import SHOW_TOOL_NAME
|
|
19
|
+
|
|
20
|
+
Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
|
|
21
|
+
"""Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
|
|
22
|
+
|
|
23
|
+
PAGE_SIZE = 25
|
|
24
|
+
|
|
25
|
+
STATUSES = ("waiting", "processing", "completed", "failed", "scheduled")
|
|
26
|
+
RUNNING = frozenset({"waiting", "processing"})
|
|
27
|
+
|
|
28
|
+
LOG_LINES = 200
|
|
29
|
+
"""Lines of logs and of errors an execution carries."""
|
|
30
|
+
|
|
31
|
+
BODY_LIMIT = 8192
|
|
32
|
+
"""Characters of the response body an execution carries."""
|
|
33
|
+
|
|
34
|
+
SUMMARY_ROWS = 10
|
|
35
|
+
SUMMARY_LOG_LINES = 20
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read(read: Read, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
project_id = arguments.get("project_id")
|
|
40
|
+
function_id = arguments.get("function_id")
|
|
41
|
+
if not isinstance(function_id, str) or not function_id:
|
|
42
|
+
raise ValueError("function_id is required for view=executions.")
|
|
43
|
+
status = arguments.get("status") or None
|
|
44
|
+
if status is not None and status not in STATUSES:
|
|
45
|
+
raise ValueError(f"status must be one of: {', '.join(STATUSES)}.")
|
|
46
|
+
cursor = arguments.get("cursor") or None
|
|
47
|
+
execution_id = arguments.get("execution_id") or None
|
|
48
|
+
target = {"function_id": function_id}
|
|
49
|
+
|
|
50
|
+
function = read("functions_get", project_id, target)
|
|
51
|
+
# One past the page tells whether another page follows.
|
|
52
|
+
queries = [Query.order_desc("$createdAt"), Query.limit(PAGE_SIZE + 1)]
|
|
53
|
+
if status:
|
|
54
|
+
queries.append(Query.equal("status", status))
|
|
55
|
+
if cursor:
|
|
56
|
+
queries.append(Query.cursor_after(str(cursor)))
|
|
57
|
+
page = read("functions_list_executions", project_id, {**target, "queries": queries})
|
|
58
|
+
executions = page.get("executions") or []
|
|
59
|
+
more = len(executions) > PAGE_SIZE
|
|
60
|
+
executions = executions[:PAGE_SIZE]
|
|
61
|
+
|
|
62
|
+
execution = None
|
|
63
|
+
if execution_id:
|
|
64
|
+
execution = _detail(
|
|
65
|
+
read(
|
|
66
|
+
"functions_get_execution",
|
|
67
|
+
project_id,
|
|
68
|
+
{**target, "execution_id": execution_id},
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
rows = [_summary(each) for each in executions]
|
|
72
|
+
return {
|
|
73
|
+
"view": "executions",
|
|
74
|
+
"projectId": project_id,
|
|
75
|
+
"functionId": function_id,
|
|
76
|
+
"name": function.get("name") or function_id,
|
|
77
|
+
"runtime": function.get("runtime"),
|
|
78
|
+
"status": status,
|
|
79
|
+
"executions": rows,
|
|
80
|
+
"total": int(page.get("total", len(rows))),
|
|
81
|
+
"cursor": cursor,
|
|
82
|
+
"next": rows[-1]["id"] if more else None,
|
|
83
|
+
"execution": execution,
|
|
84
|
+
"running": any(each["status"] in RUNNING for each in rows)
|
|
85
|
+
or bool(execution and execution["status"] in RUNNING),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _summary(execution: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"id": execution.get("$id"),
|
|
92
|
+
"status": execution.get("status") or "unknown",
|
|
93
|
+
"trigger": execution.get("trigger"),
|
|
94
|
+
"method": execution.get("requestMethod") or None,
|
|
95
|
+
"path": execution.get("requestPath") or None,
|
|
96
|
+
"statusCode": int(execution.get("responseStatusCode") or 0),
|
|
97
|
+
"duration": execution.get("duration"),
|
|
98
|
+
"createdAt": execution.get("$createdAt"),
|
|
99
|
+
"scheduledAt": execution.get("scheduledAt") or None,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _detail(execution: dict[str, Any]) -> dict[str, Any]:
|
|
104
|
+
body = execution.get("responseBody") or ""
|
|
105
|
+
logs = (execution.get("logs") or "").splitlines()
|
|
106
|
+
errors = (execution.get("errors") or "").splitlines()
|
|
107
|
+
return {
|
|
108
|
+
**_summary(execution),
|
|
109
|
+
"deploymentId": execution.get("deploymentId") or None,
|
|
110
|
+
"requestHeaders": _headers(execution.get("requestHeaders")),
|
|
111
|
+
"responseHeaders": _headers(execution.get("responseHeaders")),
|
|
112
|
+
"responseBody": body[:BODY_LIMIT],
|
|
113
|
+
"bodyTruncated": len(body) > BODY_LIMIT,
|
|
114
|
+
"logs": logs[-LOG_LINES:],
|
|
115
|
+
"logsTruncated": len(logs) > LOG_LINES,
|
|
116
|
+
"errors": errors[-LOG_LINES:],
|
|
117
|
+
"errorsTruncated": len(errors) > LOG_LINES,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _headers(headers: Any) -> list[dict[str, str]]:
|
|
122
|
+
return [
|
|
123
|
+
{"name": str(each.get("name", "")), "value": str(each.get("value", ""))}
|
|
124
|
+
for each in headers or []
|
|
125
|
+
if isinstance(each, dict)
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _request(execution: dict[str, Any]) -> str:
|
|
130
|
+
if not execution["method"]:
|
|
131
|
+
return execution["trigger"] or ""
|
|
132
|
+
return f"{execution['method']} {execution['path'] or '/'}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def summary(data: dict[str, Any]) -> str:
|
|
136
|
+
rows: list[dict[str, Any]] = data["executions"]
|
|
137
|
+
scope = f" {data['status']}" if data["status"] else ""
|
|
138
|
+
lines = [
|
|
139
|
+
f"Function {data['name']} ({data['functionId']}): {data['total']}"
|
|
140
|
+
f"{scope} executions, showing {len(rows)} newest"
|
|
141
|
+
+ (" after the cursor." if data["cursor"] else ".")
|
|
142
|
+
]
|
|
143
|
+
if rows:
|
|
144
|
+
lines.append("id | status | trigger | request | code | duration | created")
|
|
145
|
+
for each in rows[:SUMMARY_ROWS]:
|
|
146
|
+
lines.append(
|
|
147
|
+
" | ".join(
|
|
148
|
+
[
|
|
149
|
+
each["id"],
|
|
150
|
+
each["status"],
|
|
151
|
+
each["trigger"] or "",
|
|
152
|
+
_request(each),
|
|
153
|
+
str(each["statusCode"] or ""),
|
|
154
|
+
_duration(each["duration"]),
|
|
155
|
+
each["createdAt"] or "",
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
if len(rows) > SUMMARY_ROWS:
|
|
160
|
+
lines.append(f"… {len(rows) - SUMMARY_ROWS} more shown in the view.")
|
|
161
|
+
if data["next"]:
|
|
162
|
+
lines.append(
|
|
163
|
+
f"Next page: {SHOW_TOOL_NAME} with view=executions and "
|
|
164
|
+
f"cursor={data['next']}."
|
|
165
|
+
)
|
|
166
|
+
execution = data["execution"]
|
|
167
|
+
if execution:
|
|
168
|
+
lines.append(
|
|
169
|
+
f"Execution {execution['id']}: {execution['status']}, "
|
|
170
|
+
f"{_request(execution)} → {execution['statusCode'] or 'no response'} "
|
|
171
|
+
f"in {_duration(execution['duration'])}."
|
|
172
|
+
)
|
|
173
|
+
for label, key in (("Errors", "errors"), ("Logs", "logs")):
|
|
174
|
+
if execution[key]:
|
|
175
|
+
lines.append(f"{label} (last lines):")
|
|
176
|
+
lines.extend(execution[key][-SUMMARY_LOG_LINES:])
|
|
177
|
+
if execution["responseBody"]:
|
|
178
|
+
lines.append("Response body: " + execution["responseBody"][:500])
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _duration(seconds: Any) -> str:
|
|
183
|
+
# Executions that have not run yet report 0.
|
|
184
|
+
if not isinstance(seconds, (int, float)) or seconds <= 0:
|
|
185
|
+
return "–"
|
|
186
|
+
if seconds < 1:
|
|
187
|
+
return f"{seconds * 1000:.0f}ms"
|
|
188
|
+
return f"{seconds:.2f}s"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""``view=files``: a Storage bucket's files, with image previews.
|
|
2
|
+
|
|
3
|
+
The list is a page of a bucket's files, newest first, optionally narrowed by a
|
|
4
|
+
search over file names. With ``file_id`` the payload also carries that file in
|
|
5
|
+
full.
|
|
6
|
+
|
|
7
|
+
The view has no network access, so it cannot load a file's preview URL. With
|
|
8
|
+
``thumbnails`` the payload carries small previews of the page's images (and a
|
|
9
|
+
larger one of the opened file) as data URIs, rendered by Appwrite's image
|
|
10
|
+
transformations through the same hidden catalog read the model could make.
|
|
11
|
+
The model's own call leaves them out, since some hosts put
|
|
12
|
+
``structuredContent`` in the model's context; the view asks for them itself.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from contextvars import copy_context
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from appwrite_console.query import Query
|
|
23
|
+
|
|
24
|
+
from ..constants import SHOW_TOOL_NAME
|
|
25
|
+
|
|
26
|
+
Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
|
|
27
|
+
"""Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
|
|
28
|
+
|
|
29
|
+
Preview = Callable[[Any, dict[str, Any]], str | None]
|
|
30
|
+
"""Renders ``storage_get_file_preview`` for ``(project ID, arguments)`` as a
|
|
31
|
+
data URI, ``None`` when Appwrite cannot."""
|
|
32
|
+
|
|
33
|
+
PAGE_SIZE = 24
|
|
34
|
+
"""Files per page: fills the grid's rows of two, three, four and six."""
|
|
35
|
+
|
|
36
|
+
THUMBNAIL = 240
|
|
37
|
+
"""Thumbnail edge in pixels: twice the grid's cell, for dense screens."""
|
|
38
|
+
|
|
39
|
+
PREVIEW = 960
|
|
40
|
+
"""Width of the opened file's preview in pixels."""
|
|
41
|
+
|
|
42
|
+
PREVIEWABLE = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"image/jpeg",
|
|
45
|
+
"image/png",
|
|
46
|
+
"image/gif",
|
|
47
|
+
"image/webp",
|
|
48
|
+
"image/avif",
|
|
49
|
+
"image/heic",
|
|
50
|
+
"image/bmp",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
"""Types Appwrite's image transformations accept."""
|
|
54
|
+
|
|
55
|
+
PARALLEL = 8
|
|
56
|
+
"""Previews rendered at once. Each is one Appwrite request of a few hundred
|
|
57
|
+
milliseconds, so a page of 24 takes about three rounds."""
|
|
58
|
+
|
|
59
|
+
SUMMARY_ROWS = 10
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read(read: Read, preview: Preview, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
project_id = arguments.get("project_id")
|
|
64
|
+
bucket_id = arguments.get("bucket_id")
|
|
65
|
+
if not isinstance(bucket_id, str) or not bucket_id:
|
|
66
|
+
raise ValueError("bucket_id is required for view=files.")
|
|
67
|
+
search = arguments.get("search") or None
|
|
68
|
+
cursor = arguments.get("cursor") or None
|
|
69
|
+
file_id = arguments.get("file_id") or None
|
|
70
|
+
thumbnails = arguments.get("thumbnails") is True
|
|
71
|
+
target = {"bucket_id": bucket_id}
|
|
72
|
+
|
|
73
|
+
bucket = read("storage_get_bucket", project_id, target)
|
|
74
|
+
# One past the page tells whether another page follows.
|
|
75
|
+
queries = [Query.order_desc("$createdAt"), Query.limit(PAGE_SIZE + 1)]
|
|
76
|
+
if cursor:
|
|
77
|
+
queries.append(Query.cursor_after(str(cursor)))
|
|
78
|
+
listing: dict[str, Any] = {**target, "queries": queries}
|
|
79
|
+
if search:
|
|
80
|
+
listing["search"] = str(search)
|
|
81
|
+
page = read("storage_list_files", project_id, listing)
|
|
82
|
+
rows = [_file(each) for each in page.get("files") or []]
|
|
83
|
+
more = len(rows) > PAGE_SIZE
|
|
84
|
+
rows = rows[:PAGE_SIZE]
|
|
85
|
+
transforms = bool(bucket.get("transformations", True))
|
|
86
|
+
|
|
87
|
+
opened = None
|
|
88
|
+
if file_id:
|
|
89
|
+
opened = _file(
|
|
90
|
+
read("storage_get_file", project_id, {**target, "file_id": file_id})
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if thumbnails and transforms:
|
|
94
|
+
# (file, key, preview arguments) for every image preview to render.
|
|
95
|
+
wanted = [
|
|
96
|
+
(
|
|
97
|
+
row,
|
|
98
|
+
"thumbnail",
|
|
99
|
+
{"width": THUMBNAIL, "height": THUMBNAIL, "quality": 70},
|
|
100
|
+
)
|
|
101
|
+
for row in rows
|
|
102
|
+
if row["mimeType"] in PREVIEWABLE
|
|
103
|
+
]
|
|
104
|
+
if opened and opened["mimeType"] in PREVIEWABLE:
|
|
105
|
+
wanted.append((opened, "preview", {"width": PREVIEW, "quality": 80}))
|
|
106
|
+
with ThreadPoolExecutor(max_workers=PARALLEL) as pool:
|
|
107
|
+
# Each render runs in a copy of this request's context, which holds
|
|
108
|
+
# the caller's credentials.
|
|
109
|
+
futures = [
|
|
110
|
+
pool.submit(
|
|
111
|
+
copy_context().run,
|
|
112
|
+
preview,
|
|
113
|
+
project_id,
|
|
114
|
+
{**target, "file_id": each["id"], "output": "webp", **options},
|
|
115
|
+
)
|
|
116
|
+
for each, _, options in wanted
|
|
117
|
+
]
|
|
118
|
+
for (each, key, _), future in zip(wanted, futures):
|
|
119
|
+
each[key] = future.result()
|
|
120
|
+
return {
|
|
121
|
+
"view": "files",
|
|
122
|
+
"projectId": project_id,
|
|
123
|
+
"bucketId": bucket_id,
|
|
124
|
+
"bucket": {
|
|
125
|
+
"name": bucket.get("name") or bucket_id,
|
|
126
|
+
"maximumFileSize": bucket.get("maximumFileSize"),
|
|
127
|
+
"allowedFileExtensions": bucket.get("allowedFileExtensions") or [],
|
|
128
|
+
"encryption": bool(bucket.get("encryption")),
|
|
129
|
+
"antivirus": bool(bucket.get("antivirus")),
|
|
130
|
+
"compression": bucket.get("compression") or "none",
|
|
131
|
+
"fileSecurity": bool(bucket.get("fileSecurity")),
|
|
132
|
+
"transformations": transforms,
|
|
133
|
+
},
|
|
134
|
+
"search": search,
|
|
135
|
+
"files": rows,
|
|
136
|
+
"total": int(page.get("total", len(rows))),
|
|
137
|
+
"cursor": cursor,
|
|
138
|
+
"next": rows[-1]["id"] if more else None,
|
|
139
|
+
"file": opened,
|
|
140
|
+
"thumbnails": thumbnails,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _file(document: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
return {
|
|
146
|
+
"id": document.get("$id"),
|
|
147
|
+
"name": document.get("name") or "",
|
|
148
|
+
"mimeType": document.get("mimeType") or "",
|
|
149
|
+
"size": int(document.get("sizeOriginal") or 0),
|
|
150
|
+
"createdAt": document.get("$createdAt"),
|
|
151
|
+
"updatedAt": document.get("$updatedAt"),
|
|
152
|
+
"folder": document.get("folder") or "",
|
|
153
|
+
"encryption": bool(document.get("encryption")),
|
|
154
|
+
"compression": document.get("compression") or "none",
|
|
155
|
+
"chunksTotal": int(document.get("chunksTotal") or 0),
|
|
156
|
+
"chunksUploaded": int(document.get("chunksUploaded") or 0),
|
|
157
|
+
"permissions": [str(each) for each in document.get("$permissions") or []],
|
|
158
|
+
"thumbnail": None,
|
|
159
|
+
"preview": None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def size(value: int) -> str:
|
|
164
|
+
if value < 1024:
|
|
165
|
+
return f"{value} B"
|
|
166
|
+
amount = value / 1024
|
|
167
|
+
for unit in ("KB", "MB"):
|
|
168
|
+
if amount < 1024:
|
|
169
|
+
return f"{amount:.1f} {unit}"
|
|
170
|
+
amount /= 1024
|
|
171
|
+
return f"{amount:.1f} GB"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def summary(data: dict[str, Any]) -> str:
|
|
175
|
+
rows: list[dict[str, Any]] = data["files"]
|
|
176
|
+
scope = f" matching {data['search']!r}" if data["search"] else ""
|
|
177
|
+
lines = [
|
|
178
|
+
f"Bucket {data['bucket']['name']} ({data['bucketId']}): {data['total']} "
|
|
179
|
+
f"files{scope}, showing {len(rows)} newest"
|
|
180
|
+
+ (" after the cursor." if data["cursor"] else ".")
|
|
181
|
+
]
|
|
182
|
+
if rows:
|
|
183
|
+
lines.append("id | name | type | size | uploaded")
|
|
184
|
+
for row in rows[:SUMMARY_ROWS]:
|
|
185
|
+
lines.append(
|
|
186
|
+
" | ".join(
|
|
187
|
+
[
|
|
188
|
+
row["id"],
|
|
189
|
+
row["name"],
|
|
190
|
+
row["mimeType"],
|
|
191
|
+
size(row["size"]),
|
|
192
|
+
row["createdAt"] or "",
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
if len(rows) > SUMMARY_ROWS:
|
|
197
|
+
lines.append(f"… {len(rows) - SUMMARY_ROWS} more shown in the view.")
|
|
198
|
+
if data["next"]:
|
|
199
|
+
lines.append(
|
|
200
|
+
f"Next page: {SHOW_TOOL_NAME} with view=files and cursor={data['next']}."
|
|
201
|
+
)
|
|
202
|
+
opened = data["file"]
|
|
203
|
+
if opened:
|
|
204
|
+
lines.append(
|
|
205
|
+
f"File {opened['id']} ({opened['name']}): {opened['mimeType']}, "
|
|
206
|
+
f"{size(opened['size'])}, uploaded {opened['createdAt']}"
|
|
207
|
+
+ (", encrypted" if opened["encryption"] else "")
|
|
208
|
+
+ "."
|
|
209
|
+
)
|
|
210
|
+
return "\n".join(lines)
|
{mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/resource.py
RENAMED
|
@@ -54,7 +54,8 @@ def resource() -> types.Resource:
|
|
|
54
54
|
uri=uri(),
|
|
55
55
|
name="Appwrite view",
|
|
56
56
|
description=(
|
|
57
|
-
"Interactive Appwrite view: table rows
|
|
57
|
+
"Interactive Appwrite view: table rows, deployment build logs, "
|
|
58
|
+
"function executions, Storage files, Auth users and project usage."
|
|
58
59
|
),
|
|
59
60
|
mime_type=MIME_TYPE,
|
|
60
61
|
size=len(html().encode("utf-8")),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``view=usage``: a project's traffic, compute and resources over a range.
|
|
2
|
+
|
|
3
|
+
Two reads of the Usage API make the dashboard: ``usage_list_events`` for what
|
|
4
|
+
happened in each interval (requests, bandwidth in and out, executions and
|
|
5
|
+
their compute time) and ``usage_list_gauges`` for where the project stands
|
|
6
|
+
(users, files and deployment storage, and resource counts). Event series are
|
|
7
|
+
filled with zeros for intervals without traffic, so charts keep their shape.
|
|
8
|
+
|
|
9
|
+
Appwrite keeps usage history for as long as the plan allows. A range past it
|
|
10
|
+
fails with Appwrite's own message, which the view shows in place.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
|
|
21
|
+
"""Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Range:
|
|
26
|
+
interval: str
|
|
27
|
+
step: timedelta
|
|
28
|
+
points: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
RANGES = {
|
|
32
|
+
"24h": Range("1h", timedelta(hours=1), 24),
|
|
33
|
+
"7d": Range("1d", timedelta(days=1), 7),
|
|
34
|
+
"30d": Range("1d", timedelta(days=1), 30),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
EVENTS = {
|
|
38
|
+
"requests": "network.requests",
|
|
39
|
+
"inbound": "network.inbound",
|
|
40
|
+
"outbound": "network.outbound",
|
|
41
|
+
"executions": "executions",
|
|
42
|
+
"compute": "executions.compute",
|
|
43
|
+
}
|
|
44
|
+
"""Series key → Usage API event metric."""
|
|
45
|
+
|
|
46
|
+
GAUGES = {
|
|
47
|
+
"users": "users",
|
|
48
|
+
"functions": "functions",
|
|
49
|
+
"sites": "sites",
|
|
50
|
+
"databases": "databases",
|
|
51
|
+
"buckets": "buckets",
|
|
52
|
+
"filesStorage": "files.storage",
|
|
53
|
+
"deploymentsStorage": "deployments.storage",
|
|
54
|
+
"buildsStorage": "builds.storage",
|
|
55
|
+
}
|
|
56
|
+
"""Resource key → Usage API gauge metric."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read(
|
|
60
|
+
read: Read, arguments: dict[str, Any], now: datetime | None = None
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
project_id = arguments.get("project_id")
|
|
63
|
+
name = arguments.get("range") or "7d"
|
|
64
|
+
if name not in RANGES:
|
|
65
|
+
raise ValueError(f"range must be one of: {', '.join(RANGES)}.")
|
|
66
|
+
span = RANGES[name]
|
|
67
|
+
end = _floor(now or datetime.now(UTC), span.step)
|
|
68
|
+
start = end - span.step * (span.points - 1)
|
|
69
|
+
window = {
|
|
70
|
+
"interval": span.interval,
|
|
71
|
+
"start_at": start.isoformat(),
|
|
72
|
+
"order_by": "time",
|
|
73
|
+
"order_dir": "asc",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
events = read(
|
|
77
|
+
"usage_list_events",
|
|
78
|
+
project_id,
|
|
79
|
+
{**window, "metrics": list(EVENTS.values())},
|
|
80
|
+
)
|
|
81
|
+
gauges = read(
|
|
82
|
+
"usage_list_gauges",
|
|
83
|
+
project_id,
|
|
84
|
+
{**window, "metrics": list(GAUGES.values())},
|
|
85
|
+
)
|
|
86
|
+
times = [start + span.step * index for index in range(span.points)]
|
|
87
|
+
points = _points(events)
|
|
88
|
+
series = {
|
|
89
|
+
key: [points.get(metric, {}).get(time, 0) for time in times]
|
|
90
|
+
for key, metric in EVENTS.items()
|
|
91
|
+
}
|
|
92
|
+
latest = _latest(gauges)
|
|
93
|
+
return {
|
|
94
|
+
"view": "usage",
|
|
95
|
+
"projectId": project_id,
|
|
96
|
+
"range": name,
|
|
97
|
+
"interval": span.interval,
|
|
98
|
+
"times": [time.isoformat() for time in times],
|
|
99
|
+
"series": series,
|
|
100
|
+
"totals": {key: sum(values) for key, values in series.items()},
|
|
101
|
+
"resources": {key: latest.get(metric) for key, metric in GAUGES.items()},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _floor(moment: datetime, step: timedelta) -> datetime:
|
|
106
|
+
"""The start of the interval ``moment`` falls in, as Appwrite buckets it."""
|
|
107
|
+
seconds = int(step.total_seconds())
|
|
108
|
+
epoch = int(moment.timestamp())
|
|
109
|
+
return datetime.fromtimestamp(epoch - epoch % seconds, UTC)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _metrics(response: dict[str, Any]) -> list[dict[str, Any]]:
|
|
113
|
+
return [each for each in response.get("metrics") or [] if isinstance(each, dict)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _points(response: dict[str, Any]) -> dict[str, dict[datetime, int]]:
|
|
117
|
+
"""Each metric's values by interval start."""
|
|
118
|
+
found: dict[str, dict[datetime, int]] = {}
|
|
119
|
+
for metric in _metrics(response):
|
|
120
|
+
values = found.setdefault(str(metric.get("metric")), {})
|
|
121
|
+
for point in metric.get("points") or []:
|
|
122
|
+
try:
|
|
123
|
+
time = datetime.fromisoformat(str(point["time"])).astimezone(UTC)
|
|
124
|
+
except (KeyError, ValueError):
|
|
125
|
+
continue
|
|
126
|
+
values[time] = values.get(time, 0) + int(point.get("value") or 0)
|
|
127
|
+
return found
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _latest(response: dict[str, Any]) -> dict[str, int]:
|
|
131
|
+
"""Each gauge's newest snapshot."""
|
|
132
|
+
found: dict[str, int] = {}
|
|
133
|
+
for metric in _metrics(response):
|
|
134
|
+
points = metric.get("points") or []
|
|
135
|
+
if points:
|
|
136
|
+
newest = max(points, key=lambda point: str(point.get("time")))
|
|
137
|
+
found[str(metric.get("metric"))] = int(newest.get("value") or 0)
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def bytes_text(value: int) -> str:
|
|
142
|
+
amount = float(value)
|
|
143
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
144
|
+
if amount < 1000 or unit == "GB":
|
|
145
|
+
return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.1f} {unit}"
|
|
146
|
+
amount /= 1000
|
|
147
|
+
return f"{amount:.1f} TB"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def summary(data: dict[str, Any]) -> str:
|
|
151
|
+
totals = data["totals"]
|
|
152
|
+
resources = data["resources"]
|
|
153
|
+
lines = [
|
|
154
|
+
f"Usage of project {data['projectId']} over the last {data['range']} "
|
|
155
|
+
f"({data['interval']} intervals):",
|
|
156
|
+
f"Requests: {totals['requests']:,}",
|
|
157
|
+
f"Bandwidth: {bytes_text(totals['inbound'] + totals['outbound'])} "
|
|
158
|
+
f"({bytes_text(totals['inbound'])} in, {bytes_text(totals['outbound'])} out)",
|
|
159
|
+
f"Executions: {totals['executions']:,} "
|
|
160
|
+
f"({totals['compute'] / 1000:,.1f} s of compute)",
|
|
161
|
+
]
|
|
162
|
+
counts = [
|
|
163
|
+
f"{key} {resources[key]:,}"
|
|
164
|
+
for key in ("users", "functions", "sites", "databases", "buckets")
|
|
165
|
+
if resources[key] is not None
|
|
166
|
+
]
|
|
167
|
+
if counts:
|
|
168
|
+
lines.append("Now: " + ", ".join(counts) + ".")
|
|
169
|
+
storage = [
|
|
170
|
+
f"{label} {bytes_text(resources[key])}"
|
|
171
|
+
for key, label in (
|
|
172
|
+
("filesStorage", "files"),
|
|
173
|
+
("deploymentsStorage", "deployments"),
|
|
174
|
+
("buildsStorage", "builds"),
|
|
175
|
+
)
|
|
176
|
+
if resources[key] is not None
|
|
177
|
+
]
|
|
178
|
+
if storage:
|
|
179
|
+
lines.append("Storage: " + ", ".join(storage) + ".")
|
|
180
|
+
busiest = max(
|
|
181
|
+
range(len(data["times"])), key=lambda index: data["series"]["requests"][index]
|
|
182
|
+
)
|
|
183
|
+
if totals["requests"]:
|
|
184
|
+
lines.append(
|
|
185
|
+
f"Busiest interval: {data['times'][busiest]} with "
|
|
186
|
+
f"{data['series']['requests'][busiest]:,} requests."
|
|
187
|
+
)
|
|
188
|
+
return "\n".join(lines)
|