From a114afb248ec3b6015ab4bddcd4bb478a0f2b2c7 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 17 Jun 2026 14:01:35 +0100 Subject: [PATCH 01/26] Add a Sanity.io plugin --- plugins/Sanity/v1/configValidation.json | 13 + plugins/Sanity/v1/custom_types.json | 9 + .../Sanity/v1/dataStreams/changeHistory.json | 81 ++++++ .../Sanity/v1/dataStreams/customQuery.json | 72 +++++ plugins/Sanity/v1/dataStreams/datasets.json | 53 ++++ .../v1/dataStreams/documentTypeCounts.json | 60 ++++ .../Sanity/v1/dataStreams/listProjects.json | 51 ++++ plugins/Sanity/v1/dataStreams/members.json | 64 +++++ .../v1/dataStreams/scripts/changeHistory.js | 33 +++ .../dataStreams/scripts/documentTypeCounts.js | 7 + .../Sanity/v1/dataStreams/scripts/members.js | 15 + .../Sanity/v1/defaultContent/manifest.json | 6 + .../v1/defaultContent/sanityProject.dash.json | 260 ++++++++++++++++++ .../defaultContent/sanityProjects.dash.json | 49 ++++ plugins/Sanity/v1/defaultContent/scopes.json | 14 + plugins/Sanity/v1/docs/README.md | 74 +++++ plugins/Sanity/v1/icon.svg | 6 + .../Sanity/v1/indexDefinitions/default.json | 17 ++ plugins/Sanity/v1/metadata.json | 44 +++ plugins/Sanity/v1/ui.json | 42 +++ 20 files changed, 970 insertions(+) create mode 100644 plugins/Sanity/v1/configValidation.json create mode 100644 plugins/Sanity/v1/custom_types.json create mode 100644 plugins/Sanity/v1/dataStreams/changeHistory.json create mode 100644 plugins/Sanity/v1/dataStreams/customQuery.json create mode 100644 plugins/Sanity/v1/dataStreams/datasets.json create mode 100644 plugins/Sanity/v1/dataStreams/documentTypeCounts.json create mode 100644 plugins/Sanity/v1/dataStreams/listProjects.json create mode 100644 plugins/Sanity/v1/dataStreams/members.json create mode 100644 plugins/Sanity/v1/dataStreams/scripts/changeHistory.js create mode 100644 plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js create mode 100644 plugins/Sanity/v1/dataStreams/scripts/members.js create mode 100644 plugins/Sanity/v1/defaultContent/manifest.json create mode 100644 plugins/Sanity/v1/defaultContent/sanityProject.dash.json create mode 100644 plugins/Sanity/v1/defaultContent/sanityProjects.dash.json create mode 100644 plugins/Sanity/v1/defaultContent/scopes.json create mode 100644 plugins/Sanity/v1/docs/README.md create mode 100644 plugins/Sanity/v1/icon.svg create mode 100644 plugins/Sanity/v1/indexDefinitions/default.json create mode 100644 plugins/Sanity/v1/metadata.json create mode 100644 plugins/Sanity/v1/ui.json diff --git a/plugins/Sanity/v1/configValidation.json b/plugins/Sanity/v1/configValidation.json new file mode 100644 index 00000000..98d4f124 --- /dev/null +++ b/plugins/Sanity/v1/configValidation.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { + "name": "listProjects" + }, + "required": true, + "success": "Connected to the Sanity management API.", + "error": "Could not list projects. Check your Admin API token is valid and has access to your projects." + } + ] +} diff --git a/plugins/Sanity/v1/custom_types.json b/plugins/Sanity/v1/custom_types.json new file mode 100644 index 00000000..01d59910 --- /dev/null +++ b/plugins/Sanity/v1/custom_types.json @@ -0,0 +1,9 @@ +[ + { + "name": "Sanity Project", + "sourceType": "Sanity Project", + "icon": "layer-group", + "singular": "Sanity Project", + "plural": "Sanity Projects" + } +] diff --git a/plugins/Sanity/v1/dataStreams/changeHistory.json b/plugins/Sanity/v1/dataStreams/changeHistory.json new file mode 100644 index 00000000..83b7ea74 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/changeHistory.json @@ -0,0 +1,81 @@ +{ + "name": "changeHistory", + "displayName": "Change History", + "description": "Transaction history for a Sanity dataset — every content change with timestamp, author, and affected document count", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Project" + } + }, + "config": { + "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "endpointPath": "data/history/{{dataset}}/transactions", + "getArgs": [ + { "key": "excludeContent", "value": "true" }, + { "key": "reverse", "value": "true" }, + { "key": "limit", "value": "100" }, + { "key": "fromTime", "value": "{{timeframe.start}}" }, + { "key": "toTime", "value": "{{timeframe.end}}" } + ], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ], + "postRequestScript": "changeHistory.js" + }, + "ui": [ + { + "type": "text", + "name": "dataset", + "label": "Dataset", + "defaultValue": "production", + "validation": { "required": true }, + "help": "The dataset to read change history from (e.g. production)." + } + ], + "metadata": [ + { + "name": "timestamp", + "displayName": "Time", + "shape": "date", + "role": "timestamp" + }, + { + "name": "transactionId", + "displayName": "Transaction ID", + "shape": "string", + "role": "id" + }, + { + "name": "author", + "displayName": "Author", + "shape": "string", + "role": "label" + }, + { + "name": "documentCount", + "displayName": "Documents Changed", + "shape": "number", + "role": "value" + } + ], + "timeframes": [ + "last1hour", + "last12hours", + "last24hours", + "last7days", + "last30days", + "lastMonth", + "lastQuarter", + "lastYear" + ] +} diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json new file mode 100644 index 00000000..dbf9eae1 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -0,0 +1,72 @@ +{ + "name": "customQuery", + "displayName": "Custom GROQ Query", + "description": "Run an arbitrary GROQ query against a Sanity dataset", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Project" + } + }, + "config": { + "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "expandInnerObjects": true, + "endpointPath": "data/query/{{dataset}}", + "pathToData": "result", + "getArgs": [ + { + "key": "query", + "value": "{{query}}" + }, + { + "key": "perspective", + "value": "{{perspective}}" + } + ], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ] + }, + "ui": [ + { + "type": "text", + "name": "dataset", + "label": "Dataset", + "defaultValue": "production", + "validation": { "required": true }, + "help": "The dataset to query." + }, + { + "type": "code", + "name": "query", + "language": "groq", + "label": "GROQ query", + "defaultValue": "*[0...20]{_id, _type, _updatedAt}", + "validation": { "required": true }, + "help": "See [GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet) for patterns" + }, + { + "type": "switch", + "name": "perspective", + "label": "Perspective", + "defaultValue": "published", + "options": [ + { "value": "published", "label": "Published" }, + { "value": "drafts", "label": "Drafts" }, + { "value": "raw", "label": "Raw" } + ] + } + ], + "metadata": [{ "pattern": ".*" }], + "timeframes": false, + "manualConfigApply": true +} diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json new file mode 100644 index 00000000..3b1fbb8a --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -0,0 +1,53 @@ +{ + "name": "datasets", + "displayName": "Datasets", + "description": "Lists the datasets in a Sanity project, with their access-control mode.", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Project" + } + }, + "config": { + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "expandInnerObjects": true, + "endpointPath": "projects/{{object.rawId}}/datasets", + "getArgs": [], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ] + }, + "metadata": [ + { + "name": "name", + "shape": "string", + "displayName": "Dataset", + "role": "label" + }, + { + "name": "value", + "displayName": "Dataset name", + "computed": true, + "valueExpression": "{{ $['name'] }}", + "role": "value", + "visible": false + }, + { + "name": "aclMode", + "shape": "string", + "displayName": "Access mode" + }, + { + "pattern": ".*" + } + ], + "timeframes": false +} diff --git a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json new file mode 100644 index 00000000..ace468c7 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json @@ -0,0 +1,60 @@ +{ + "name": "documentTypeCounts", + "displayName": "Document Type Counts", + "description": "Count of published documents per type in a Sanity dataset", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Project" + } + }, + "config": { + "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "endpointPath": "data/query/{{dataset}}", + "getArgs": [ + { + "key": "query", + "value": "*[!(_id in path(\"drafts.**\"))]._type" + } + ], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ], + "postRequestScript": "documentTypeCounts.js" + }, + "ui": [ + { + "type": "text", + "name": "dataset", + "label": "Dataset", + "defaultValue": "production", + "validation": { "required": true }, + "help": "The dataset to analyse (e.g. production)." + } + ], + "metadata": [ + { + "name": "documentType", + "displayName": "Document Type", + "shape": "string", + "role": "label" + }, + { + "name": "count", + "displayName": "Count", + "shape": "number", + "role": "value" + } + ], + "timeframes": false, + "manualConfigApply": true +} diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json new file mode 100644 index 00000000..a673b6e0 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -0,0 +1,51 @@ +{ + "name": "listProjects", + "displayName": "Projects", + "description": "Lists all Sanity projects the admin token can access. Backs config validation and the project import.", + "tags": ["Advanced"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "expandInnerObjects": false, + "endpointPath": "projects", + "getArgs": [], + "headers": [] + }, + "metadata": [ + { + "name": "id", + "shape": "string", + "displayName": "Project ID" + }, + { + "name": "displayName", + "shape": "string", + "displayName": "Name" + }, + { + "name": "organizationId", + "shape": "string", + "displayName": "Organization ID" + }, + { + "name": "studioHost", + "shape": "string", + "displayName": "Studio host" + }, + { + "name": "createdAt", + "shape": "string", + "displayName": "Created" + }, + { + "pattern": ".*" + } + ], + "timeframes": false, + "visibility": { + "type": "hidden" + } +} diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json new file mode 100644 index 00000000..40bc71de --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -0,0 +1,64 @@ +{ + "name": "members", + "displayName": "Members", + "description": "People with access to a Sanity project, with their roles", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Project" + } + }, + "config": { + "baseUrl": "https://api.sanity.io/v2025-07-11/", + "httpMethod": "get", + "endpointPath": "access/project/{{object.rawId}}/users", + "getArgs": [], + "paging": { + "mode": "token", + "pageSize": { + "realm": "queryArg", + "path": "limit", + "value": "100" + }, + "in": { + "realm": "payload", + "path": "nextCursor" + }, + "out": { + "realm": "queryArg", + "path": "nextCursor" + } + }, + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ], + "postRequestScript": "members.js" + }, + "metadata": [ + { + "name": "name", + "displayName": "Name", + "shape": "string", + "role": "label" + }, + { + "name": "email", + "displayName": "Email", + "shape": "string" + }, + { + "name": "roles", + "displayName": "Roles", + "shape": "string" + }, + { + "pattern": ".*" + } + ], + "timeframes": false +} diff --git a/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js b/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js new file mode 100644 index 00000000..cf138c79 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js @@ -0,0 +1,33 @@ +// Parse NDJSON response from Sanity history/transactions endpoint. +// The endpoint returns one JSON object per line (not a JSON array). +// data will be null/undefined; raw text is in response.body. + +const body = response.body; + +// Defensive: if the handler somehow parsed it already, handle that case +if (Array.isArray(body)) { + result = body.map(function(tx) { + return { + timestamp: tx.timestamp, + transactionId: tx.id, + author: tx.author, + documentCount: Array.isArray(tx.documentIDs) ? tx.documentIDs.length : 0 + }; + }); +} else if (typeof body === 'string') { + result = body + .split('\n') + .map(function(line) { return line.trim(); }) + .filter(function(line) { return line.length > 0; }) + .map(function(line) { return JSON.parse(line); }) + .map(function(tx) { + return { + timestamp: tx.timestamp, + transactionId: tx.id, + author: tx.author, + documentCount: Array.isArray(tx.documentIDs) ? tx.documentIDs.length : 0 + }; + }); +} else { + result = []; +} diff --git a/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js b/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js new file mode 100644 index 00000000..00e3cd92 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js @@ -0,0 +1,7 @@ +// data.result is an array of _type strings for all published documents. +// Use lodash countBy to tally occurrences, then emit one row per type. +const counts = _.countBy(data.result || []); +result = Object.entries(counts).map(([documentType, count]) => ({ + documentType, + count, +})); diff --git a/plugins/Sanity/v1/dataStreams/scripts/members.js b/plugins/Sanity/v1/dataStreams/scripts/members.js new file mode 100644 index 00000000..2d0fb358 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/members.js @@ -0,0 +1,15 @@ +// dataStreams/scripts/members.js +// Flatten each member into name, email, roles columns. +// `data` is the parsed response body: { data: [...], nextCursor, totalCount } +// pathToData is ignored when postRequestScript is set, so navigate manually. +const members = (data && data.data) || []; + +result = members.map((user) => ({ + name: user.profile && user.profile.displayName, + email: user.profile && user.profile.email, + roles: (user.memberships || []) + .flatMap((m) => m.roleNames || []) + .join(", "), + sanityUserId: user.sanityUserId, + imageUrl: user.profile && user.profile.imageUrl, +})); diff --git a/plugins/Sanity/v1/defaultContent/manifest.json b/plugins/Sanity/v1/defaultContent/manifest.json new file mode 100644 index 00000000..232ef74a --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/manifest.json @@ -0,0 +1,6 @@ +{ + "items": [ + { "name": "sanityProjects", "type": "dashboard" }, + { "name": "sanityProject", "type": "dashboard" } + ] +} diff --git a/plugins/Sanity/v1/defaultContent/sanityProject.dash.json b/plugins/Sanity/v1/defaultContent/sanityProject.dash.json new file mode 100644 index 00000000..7391f4dc --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/sanityProject.dash.json @@ -0,0 +1,260 @@ +{ + "name": "Project", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": ["{{variables.[Sanity Project]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "f1ecb7e1-1d8e-449c-b39d-521f6b48dfb7", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Project Details", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "c52cce1c-efca-48f0-b908-c1d448fadc29", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Document Type Counts", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[documentTypeCounts]}}", + "name": "documentTypeCounts", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dataset": "production" + }, + "sort": { + "by": [["count", "desc"]], + "top": 20 + } + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "documentType", + "yAxisData": ["count"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Documents", + "showXAxisLabel": true, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "vertical", + "displayMode": "actual", + "showTotals": false, + "showValue": false, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "f4d3e130-21b5-4575-bcd9-05460086200b", + "x": 0, + "y": 4, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Changes", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[changeHistory]}}", + "name": "changeHistory", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dataset": "production" + } + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["timestamp", "author", "documentCount", "transactionId"] + } + } + } + } + }, + { + "i": "4ded0fa6-3ac1-4b0a-af03-123928006951", + "x": 0, + "y": 8, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Datasets", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasets]}}", + "name": "datasets", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "aclMode"] + } + } + } + } + }, + { + "i": "cb6c1ad8-25f4-4e50-b5c9-619f1cadd6f8", + "x": 2, + "y": 8, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Members", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[members]}}", + "name": "members", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "email", "roles"] + } + } + } + } + }, + { + "i": "b36972d8-2fa5-46fa-987b-f811ffa53ff2", + "x": 0, + "y": 12, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Custom GROQ Query", + "description": "Edit the tile to customise the GROQ query and dataset.", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[customQuery]}}", + "name": "customQuery", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "dataset": "production", + "query": "*[0...20]{_id, _type, _updatedAt}", + "perspective": "published" + } + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sanity Project]}}" + }, + "variables": ["{{variables.[Sanity Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false + } + } + } + } + } + ] + } +} diff --git a/plugins/Sanity/v1/defaultContent/sanityProjects.dash.json b/plugins/Sanity/v1/defaultContent/sanityProjects.dash.json new file mode 100644 index 00000000..02ee7432 --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/sanityProjects.dash.json @@ -0,0 +1,49 @@ +{ + "name": "Projects", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "9e3a54e7-80c9-42df-b8fc-94decb1c5505", + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sanity Projects", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sanity Projects]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-blocks", + "config": { + "data-stream-blocks": { + "labelColumn": "name", + "stateColumn": "none", + "linkColumn": "name", + "columns": 4 + } + } + } + } + } + ] + } +} diff --git a/plugins/Sanity/v1/defaultContent/scopes.json b/plugins/Sanity/v1/defaultContent/scopes.json new file mode 100644 index 00000000..4bc1306a --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/scopes.json @@ -0,0 +1,14 @@ +[ + { + "name": "Sanity Projects", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Sanity Project"] } + }, + "variable": { + "name": "Sanity Project", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md new file mode 100644 index 00000000..9787395f --- /dev/null +++ b/plugins/Sanity/v1/docs/README.md @@ -0,0 +1,74 @@ +# Sanity + +Bring your [Sanity](https://www.sanity.io) content into SquaredUp. A single plugin instance can cover **many Sanity projects** at once: it lists the projects your admin token can see, and — for each project you supply an API token for — lets you browse datasets, run GROQ queries, break content down by document type, and see a full **history of changes**. + +## What this plugin does + +You give the plugin two things: an **admin token** (used to list projects, datasets and members across your organization) and a set of **per-project API tokens** (used to query each project's content). It then imports: + +- **Each project** your admin token can see, as a `Sanity Project` object you can scope dashboards to and drill into. + +From there you get data streams for: + +- **Change history** — a project/dataset's transaction log: who changed what and when, over any dashboard timeframe. +- **Document type counts** — how many documents of each `_type` live in a dataset. +- **Custom GROQ query** — run any GROQ query against a dataset straight from a tile. +- **Datasets** — a table of a project's datasets and their access modes. +- **Members** (optional) — the people with access to a project and their roles. + +## Why two kinds of token? + +Sanity uses two API surfaces: + +- **Management** (`api.sanity.io`) — listing projects, datasets and members. The plugin uses your **admin token** here. +- **Content** (`.api.sanity.io`) — GROQ queries and history. The plugin uses the **per-project token** matching the project being queried. + +This keeps content access least-privilege: each project's token can only read that project. The query and history tiles work for any project you've added a token for; projects without a token still appear (with their datasets/members via the admin token) but their query/history tiles will show an authorization error. + +## Prerequisites — getting your credentials + +### Admin API token + +1. Sign in to [sanity.io/manage](https://www.sanity.io/manage). +2. Choose an organization-level or project token with enough access to list the projects you care about. A **personal token** (from an admin account) or an organization token works well. To also populate the optional **Members** tile, the token needs member-read access (an Administrator/personal token). +3. If creating a project token: open a project → **API → Tokens → Add API token**, and copy the value (shown once). + +### Project IDs and per-project tokens + +For **each** project you want to query content in: + +1. In [sanity.io/manage](https://www.sanity.io/manage), open the project. +2. Note the **Project ID** — an 8-character string shown on the project page and in its URL (`…/manage/project/`). +3. Go to **API → Tokens → Add API token**, choose the **Viewer** role (read-only), and copy the token value. + +> **Private datasets** require a token; **public** datasets can be queried without one, but a token is still recommended. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +|---|---|---|---| +| **Admin API token** | Token used for management calls (listing projects, datasets, members) | sanity.io/manage → API → Tokens (or a personal/admin token) | Yes | +| **Project API tokens** | A list of **Project ID → API token** pairs, one per project you want to query | Project ID from the project page; Viewer token from API → Tokens | Yes | +| **Content API version** | The dated Sanity API version used for GROQ/history (advanced) | Defaults to `v2025-02-19`; leave as-is unless you need a specific version | No | + +## What gets indexed + +| Object type | Represents | Example | +|---|---|---| +| **Sanity Project** | A project your admin token can see | `My Studio (abc12xyz)` | + +Datasets are **not** indexed as objects (Sanity has no cross-project datasets endpoint). Instead, each project's datasets appear as a table, and you choose a dataset via the **dataset** parameter on the query, history and document-count tiles. + +## Known limitations + +- **No usage or billing metrics.** Sanity's HTTP API exposes no API-request counts, bandwidth, storage, or plan/quota figures. +- **Content tiles need a per-project token.** Query, history and document-count tiles for a project only work if you've added that project's token under **Project API tokens**. +- **Document type counts read document metadata.** The breakdown comes from a GROQ query returning each document's `_type`; on very large datasets it can be slow — scope it to specific types if needed. +- **Change history depth.** The transaction log is fetched per dashboard timeframe with a capped number of transactions per request; very busy windows may be truncated to the most recent transactions. +- **Members tile permissions.** Requires the admin token to have member-read access; otherwise the tile shows an authorization error and nothing else is affected. + +## Useful links + +- [Sanity HTTP API reference](https://www.sanity.io/docs/http-reference) +- [GROQ query language](https://www.sanity.io/docs/groq) +- [Manage your Sanity projects](https://www.sanity.io/manage) diff --git a/plugins/Sanity/v1/icon.svg b/plugins/Sanity/v1/icon.svg new file mode 100644 index 00000000..9f818e07 --- /dev/null +++ b/plugins/Sanity/v1/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json new file mode 100644 index 00000000..95ba3c2b --- /dev/null +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -0,0 +1,17 @@ +{ + "steps": [ + { + "name": "projects", + "dataStream": { + "name": "listProjects" + }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "displayName", + "type": { "value": "Sanity Project" }, + "properties": ["organizationId", "studioHost", "createdAt"] + } + } + ] +} diff --git a/plugins/Sanity/v1/metadata.json b/plugins/Sanity/v1/metadata.json new file mode 100644 index 00000000..d7d169e3 --- /dev/null +++ b/plugins/Sanity/v1/metadata.json @@ -0,0 +1,44 @@ +{ + "name": "sanity", + "displayName": "Sanity", + "version": "1.0.0", + "author": { + "name": "@andrewharris", + "type": "labs" + }, + "description": "Connect one or more Sanity projects: list projects, browse datasets, run GROQ queries, and track a full history of content changes — using an admin token plus per-project API tokens.", + "category": "Database", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": ["sanity", "cms", "headless", "groq", "content", "dataset"], + "objectTypes": ["Sanity Project"], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/Sanity/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/Sanity/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "https://api.sanity.io/v2021-06-07/", + "authMode": "none", + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}" + } + ], + "queryArgs": [] + } + } +} diff --git a/plugins/Sanity/v1/ui.json b/plugins/Sanity/v1/ui.json new file mode 100644 index 00000000..a37a3b2a --- /dev/null +++ b/plugins/Sanity/v1/ui.json @@ -0,0 +1,42 @@ +[ + { + "type": "password", + "name": "adminToken", + "label": "Admin API token", + "validation": { + "required": true + }, + "placeholder": "Enter an organization or admin API token", + "help": "Used for management calls — listing projects, datasets and members. Use an organization token or a personal token from an admin account. To populate the optional Members tile this token needs member-read access (Administrator/personal). Create tokens in [sanity.io/manage](https://www.sanity.io/manage) → your project → API → Tokens." + }, + { + "type": "key-value", + "name": "projects", + "label": "Project API tokens", + "displayName": "project", + "verb": "→", + "keyInput": { + "title": "Project ID", + "placeholder": "abc12xyz", + "validation": { + "required": true + } + }, + "valueInput": { + "title": "API token (Viewer)", + "placeholder": "sk...", + "validation": { + "required": true + } + }, + "help": "For each Sanity project you want to query, add its **Project ID** and a project API token (Viewer role). The Project ID is shown on the project page in [sanity.io/manage](https://www.sanity.io/manage); create a token under that project's API → Tokens. Query and history tiles only work for projects listed here." + }, + { + "type": "text", + "name": "apiVersion", + "label": "Content API version", + "defaultValue": "v2025-02-19", + "placeholder": "v2025-02-19", + "help": "Advanced: the dated Sanity API version used for GROQ queries and change history. Leave as the default unless you need a specific version." + } +] From 604a72c82c65b4fbdbeedb7c04b336853c1f681e Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Thu, 18 Jun 2026 06:41:18 +0100 Subject: [PATCH 02/26] index sanity datasets using dependent imports --- plugins/Sanity/v1/dataStreams/datasets.json | 47 +++++++++++++++---- .../Sanity/v1/dataStreams/scripts/datasets.js | 9 ++++ .../Sanity/v1/indexDefinitions/default.json | 23 +++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 plugins/Sanity/v1/dataStreams/scripts/datasets.js diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index 3b1fbb8a..f4ca7a72 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -18,6 +18,7 @@ "expandInnerObjects": true, "endpointPath": "projects/{{object.rawId}}/datasets", "getArgs": [], + "postRequestScript": "datasets.js", "headers": [ { "key": "Authorization", @@ -27,18 +28,16 @@ }, "metadata": [ { - "name": "name", + "name": "projectId", + "displayName": "Project ID", "shape": "string", - "displayName": "Dataset", - "role": "label" + "visible": false }, { - "name": "value", - "displayName": "Dataset name", - "computed": true, - "valueExpression": "{{ $['name'] }}", - "role": "value", - "visible": false + "name": "datasetId", + "shape": "string", + "displayName": "Dataset", + "role": "label" }, { "name": "aclMode", @@ -46,7 +45,35 @@ "displayName": "Access mode" }, { - "pattern": ".*" + "name": "createdAt", + "shape": "date", + "displayName": "Created At" + }, + { + "name": "createdByUserId", + "shape": "string", + "displayName": "Created By User Id" + }, + { + "name": "projectName", + "displayName": "Project Name", + "shape": "string" + }, + { + "name": "uid", + "displayName": "uid", + "shape": "string", + "computed": true, + "valueExpression": "{{ $['projectId'] + '-' + $['datasetId'] }}", + "visible": false + }, + { + "name": "displayName", + "displayName": "Display Name", + "shape": "string", + "computed": true, + "valueExpression": "{{ $['projectName'] + ' - ' + $['datasetId'] }}", + "visible": false } ], "timeframes": false diff --git a/plugins/Sanity/v1/dataStreams/scripts/datasets.js b/plugins/Sanity/v1/dataStreams/scripts/datasets.js new file mode 100644 index 00000000..17769ec5 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/datasets.js @@ -0,0 +1,9 @@ +const object = context.objects[0]; + +result = (data || []).map((d) => ({ + ...d, + datasetId: d.name, + projectName: object?.name, + projectId: object?.rawId, +})); + diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json index 95ba3c2b..f9b58540 100644 --- a/plugins/Sanity/v1/indexDefinitions/default.json +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -12,6 +12,29 @@ "type": { "value": "Sanity Project" }, "properties": ["organizationId", "studioHost", "createdAt"] } + }, + { + "name": "datasets", + "dataStream": { + "name": "datasets" + }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Sanity Project\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "uid", + "name": "displayName", + "type": { "value": "Sanity Dataset" }, + "properties": [ + "datasetId", + "aclMode", + "createdAt", + "createdByUserId", + "projectId" + ] + }, + "optional": true } ] } From 3dd494a66dde1b961f29a924738306783c46eaef Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Thu, 18 Jun 2026 06:41:43 +0100 Subject: [PATCH 03/26] Use scoped dataset datastreams --- .../Sanity/v1/dataStreams/changeHistory.json | 19 +++++-------------- .../Sanity/v1/dataStreams/customQuery.json | 17 +++++------------ .../v1/dataStreams/documentTypeCounts.json | 19 +++++-------------- plugins/Sanity/v1/defaultContent/scopes.json | 12 ++++++++++++ 4 files changed, 27 insertions(+), 40 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/changeHistory.json b/plugins/Sanity/v1/dataStreams/changeHistory.json index 83b7ea74..5c764857 100644 --- a/plugins/Sanity/v1/dataStreams/changeHistory.json +++ b/plugins/Sanity/v1/dataStreams/changeHistory.json @@ -7,16 +7,16 @@ "matches": { "sourceType": { "type": "equals", - "value": "Sanity Project" + "value": "Sanity Dataset" } }, "config": { - "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", "httpMethod": "get", "paging": { "mode": "none" }, - "endpointPath": "data/history/{{dataset}}/transactions", + "endpointPath": "data/history/{{object.datasetId}}/transactions", "getArgs": [ { "key": "excludeContent", "value": "true" }, { "key": "reverse", "value": "true" }, @@ -27,21 +27,12 @@ "headers": [ { "key": "Authorization", - "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" } ], "postRequestScript": "changeHistory.js" }, - "ui": [ - { - "type": "text", - "name": "dataset", - "label": "Dataset", - "defaultValue": "production", - "validation": { "required": true }, - "help": "The dataset to read change history from (e.g. production)." - } - ], + "metadata": [ { "name": "timestamp", diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json index dbf9eae1..6dd0cb01 100644 --- a/plugins/Sanity/v1/dataStreams/customQuery.json +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -7,17 +7,18 @@ "matches": { "sourceType": { "type": "equals", - "value": "Sanity Project" + "value": "Sanity Dataset" } }, + "providesPluginDiagnostics": true, "config": { - "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", "httpMethod": "get", "paging": { "mode": "none" }, "expandInnerObjects": true, - "endpointPath": "data/query/{{dataset}}", + "endpointPath": "data/query/{{object.datasetId}}", "pathToData": "result", "getArgs": [ { @@ -32,19 +33,11 @@ "headers": [ { "key": "Authorization", - "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" } ] }, "ui": [ - { - "type": "text", - "name": "dataset", - "label": "Dataset", - "defaultValue": "production", - "validation": { "required": true }, - "help": "The dataset to query." - }, { "type": "code", "name": "query", diff --git a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json index ace468c7..e1d85ed0 100644 --- a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json +++ b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json @@ -7,16 +7,16 @@ "matches": { "sourceType": { "type": "equals", - "value": "Sanity Project" + "value": "Sanity Dataset" } }, "config": { - "baseUrl": "https://{{object.rawId}}.api.sanity.io/{{dataSource.apiVersion}}/", + "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", "httpMethod": "get", "paging": { "mode": "none" }, - "endpointPath": "data/query/{{dataset}}", + "endpointPath": "data/query/{{object.datasetId}}", "getArgs": [ { "key": "query", @@ -26,21 +26,12 @@ "headers": [ { "key": "Authorization", - "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" } ], "postRequestScript": "documentTypeCounts.js" }, - "ui": [ - { - "type": "text", - "name": "dataset", - "label": "Dataset", - "defaultValue": "production", - "validation": { "required": true }, - "help": "The dataset to analyse (e.g. production)." - } - ], + "ui": [], "metadata": [ { "name": "documentType", diff --git a/plugins/Sanity/v1/defaultContent/scopes.json b/plugins/Sanity/v1/defaultContent/scopes.json index 4bc1306a..99d1ae43 100644 --- a/plugins/Sanity/v1/defaultContent/scopes.json +++ b/plugins/Sanity/v1/defaultContent/scopes.json @@ -10,5 +10,17 @@ "default": "none", "type": "object" } + }, + { + "name": "Sanity Datasets", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Sanity Dataset"] } + }, + "variable": { + "name": "Sanity Project", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } } ] From 1a2cbeaa00aa9580259f43f600fc0f8ae426533f Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Thu, 18 Jun 2026 06:41:53 +0100 Subject: [PATCH 04/26] Add dataset stats dataStream --- .../Sanity/v1/dataStreams/datasetStats.json | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 plugins/Sanity/v1/dataStreams/datasetStats.json diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json new file mode 100644 index 00000000..e9ad6f4d --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -0,0 +1,45 @@ +{ + "name": "datasetStats", + "displayName": "Dataset Stats", + "description": "Get stats on how close to usage limits this dataset is", + "tags": ["Sanity"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Sanity Dataset" + } + }, + "providesPluginDiagnostics": true, + "config": { + "baseUrl": "https://{{object.projectId}}.api.sanity.io/v1/", + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "endpointPath": "data/stats/{{object.datasetId}}", + "getArgs": [], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" + } + ], + "expandInnerObjects": true + }, + "ui": [ + { + "type": "text", + "name": "hostname", + "label": "Hostname", + "placeholder": "api.example.com" + } + ], + "metadata": [ + { + "pattern": ".*" + } + ], + "timeframes": false, + "manualConfigApply": true +} From 6b65bc8954ae4b2b564fcbb532ffd74ffc38cf5b Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 24 Jun 2026 10:31:25 +0100 Subject: [PATCH 05/26] Update Sanity data stream tags --- plugins/Sanity/v1/dataStreams/changeHistory.json | 2 +- plugins/Sanity/v1/dataStreams/customQuery.json | 2 +- plugins/Sanity/v1/dataStreams/datasetStats.json | 2 +- plugins/Sanity/v1/dataStreams/datasets.json | 2 +- plugins/Sanity/v1/dataStreams/documentTypeCounts.json | 2 +- plugins/Sanity/v1/dataStreams/listProjects.json | 2 +- plugins/Sanity/v1/dataStreams/members.json | 2 +- plugins/Sanity/v1/indexDefinitions/default.json | 3 ++- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/changeHistory.json b/plugins/Sanity/v1/dataStreams/changeHistory.json index 5c764857..6f977203 100644 --- a/plugins/Sanity/v1/dataStreams/changeHistory.json +++ b/plugins/Sanity/v1/dataStreams/changeHistory.json @@ -2,7 +2,7 @@ "name": "changeHistory", "displayName": "Change History", "description": "Transaction history for a Sanity dataset — every content change with timestamp, author, and affected document count", - "tags": ["Sanity"], + "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json index 6dd0cb01..53840e2c 100644 --- a/plugins/Sanity/v1/dataStreams/customQuery.json +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -2,7 +2,7 @@ "name": "customQuery", "displayName": "Custom GROQ Query", "description": "Run an arbitrary GROQ query against a Sanity dataset", - "tags": ["Sanity"], + "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json index e9ad6f4d..d752ae4b 100644 --- a/plugins/Sanity/v1/dataStreams/datasetStats.json +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -2,7 +2,7 @@ "name": "datasetStats", "displayName": "Dataset Stats", "description": "Get stats on how close to usage limits this dataset is", - "tags": ["Sanity"], + "tags": ["Analytics"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index f4ca7a72..3a26b6a6 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -2,7 +2,7 @@ "name": "datasets", "displayName": "Datasets", "description": "Lists the datasets in a Sanity project, with their access-control mode.", - "tags": ["Sanity"], + "tags": ["Datasets"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json index e1d85ed0..73877882 100644 --- a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json +++ b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json @@ -2,7 +2,7 @@ "name": "documentTypeCounts", "displayName": "Document Type Counts", "description": "Count of published documents per type in a Sanity dataset", - "tags": ["Sanity"], + "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json index a673b6e0..8badf4ee 100644 --- a/plugins/Sanity/v1/dataStreams/listProjects.json +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -2,7 +2,7 @@ "name": "listProjects", "displayName": "Projects", "description": "Lists all Sanity projects the admin token can access. Backs config validation and the project import.", - "tags": ["Advanced"], + "tags": ["Project"], "baseDataSourceName": "httpRequestUnscoped", "config": { "httpMethod": "get", diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index 40bc71de..3c89940e 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -2,7 +2,7 @@ "name": "members", "displayName": "Members", "description": "People with access to a Sanity project, with their roles", - "tags": ["Sanity"], + "tags": ["Members"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json index f9b58540..dc0b2a79 100644 --- a/plugins/Sanity/v1/indexDefinitions/default.json +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -34,7 +34,8 @@ "projectId" ] }, - "optional": true + "optional": true, + "dependsOn": ["projects"] } ] } From b255f929145aaae0e4934f5d586c757999d7141a Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 1 Jul 2026 14:49:15 +0100 Subject: [PATCH 06/26] Sanity plugin cleanup --- plugins/Sanity/v1/custom_types.json | 15 ++++-- .../Sanity/v1/dataStreams/changeHistory.json | 7 ++- .../Sanity/v1/dataStreams/customQuery.json | 4 +- .../Sanity/v1/dataStreams/datasetStats.json | 2 +- plugins/Sanity/v1/dataStreams/datasets.json | 4 +- .../v1/dataStreams/documentTypeCounts.json | 4 +- .../Sanity/v1/dataStreams/listProjects.json | 2 +- plugins/Sanity/v1/dataStreams/members.json | 17 ++++++- .../v1/dataStreams/scripts/changeHistory.js | 40 +++++++-------- .../Sanity/v1/dataStreams/scripts/members.js | 2 + ...ityProject.dash.json => dataset.dash.json} | 49 ++++++++++--------- .../Sanity/v1/defaultContent/manifest.json | 4 +- ...yProjects.dash.json => projects.dash.json} | 4 +- plugins/Sanity/v1/defaultContent/scopes.json | 12 ++--- .../Sanity/v1/indexDefinitions/default.json | 30 ++++++++++-- plugins/Sanity/v1/metadata.json | 6 +-- 16 files changed, 125 insertions(+), 77 deletions(-) rename plugins/Sanity/v1/defaultContent/{sanityProject.dash.json => dataset.dash.json} (85%) rename plugins/Sanity/v1/defaultContent/{sanityProjects.dash.json => projects.dash.json} (93%) diff --git a/plugins/Sanity/v1/custom_types.json b/plugins/Sanity/v1/custom_types.json index 01d59910..3511d0ad 100644 --- a/plugins/Sanity/v1/custom_types.json +++ b/plugins/Sanity/v1/custom_types.json @@ -1,9 +1,16 @@ [ { - "name": "Sanity Project", - "sourceType": "Sanity Project", + "name": "Project", + "sourceType": "Project", "icon": "layer-group", - "singular": "Sanity Project", - "plural": "Sanity Projects" + "singular": "Project", + "plural": "Projects" + }, + { + "name": "Dataset", + "sourceType": "Dataset", + "icon": "layer-group", + "singular": "Dataset", + "plural": "Datasets" } ] diff --git a/plugins/Sanity/v1/dataStreams/changeHistory.json b/plugins/Sanity/v1/dataStreams/changeHistory.json index 6f977203..be719692 100644 --- a/plugins/Sanity/v1/dataStreams/changeHistory.json +++ b/plugins/Sanity/v1/dataStreams/changeHistory.json @@ -1,13 +1,13 @@ { "name": "changeHistory", "displayName": "Change History", - "description": "Transaction history for a Sanity dataset — every content change with timestamp, author, and affected document count", + "description": "Transaction history for a dataset — every content change with timestamp, author, and affected document count", "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { "type": "equals", - "value": "Sanity Dataset" + "value": "Dataset" } }, "config": { @@ -57,6 +57,9 @@ "displayName": "Documents Changed", "shape": "number", "role": "value" + }, + { + "pattern": ".*" } ], "timeframes": [ diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json index 53840e2c..adcd32f2 100644 --- a/plugins/Sanity/v1/dataStreams/customQuery.json +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -1,13 +1,13 @@ { "name": "customQuery", "displayName": "Custom GROQ Query", - "description": "Run an arbitrary GROQ query against a Sanity dataset", + "description": "Run an arbitrary GROQ query against a dataset", "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { "type": "equals", - "value": "Sanity Dataset" + "value": "Dataset" } }, "providesPluginDiagnostics": true, diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json index d752ae4b..7e10783d 100644 --- a/plugins/Sanity/v1/dataStreams/datasetStats.json +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -7,7 +7,7 @@ "matches": { "sourceType": { "type": "equals", - "value": "Sanity Dataset" + "value": "Dataset" } }, "providesPluginDiagnostics": true, diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index 3a26b6a6..e5e0b9b7 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -1,13 +1,13 @@ { "name": "datasets", "displayName": "Datasets", - "description": "Lists the datasets in a Sanity project, with their access-control mode.", + "description": "Lists the datasets in a project, with their access-control mode.", "tags": ["Datasets"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { "type": "equals", - "value": "Sanity Project" + "value": "Project" } }, "config": { diff --git a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json index 73877882..8fbe48cc 100644 --- a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json +++ b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json @@ -1,13 +1,13 @@ { "name": "documentTypeCounts", "displayName": "Document Type Counts", - "description": "Count of published documents per type in a Sanity dataset", + "description": "Count of published documents per type in a dataset", "tags": ["Documents"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { "type": "equals", - "value": "Sanity Dataset" + "value": "Dataset" } }, "config": { diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json index 8badf4ee..bd5bfc8d 100644 --- a/plugins/Sanity/v1/dataStreams/listProjects.json +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -1,7 +1,7 @@ { "name": "listProjects", "displayName": "Projects", - "description": "Lists all Sanity projects the admin token can access. Backs config validation and the project import.", + "description": "Lists all projects the admin token can access. Backs config validation and the project import.", "tags": ["Project"], "baseDataSourceName": "httpRequestUnscoped", "config": { diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index 3c89940e..ebab5681 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -1,13 +1,13 @@ { "name": "members", "displayName": "Members", - "description": "People with access to a Sanity project, with their roles", + "description": "People with access to a project, with their roles", "tags": ["Members"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { "sourceType": { "type": "equals", - "value": "Sanity Project" + "value": "Project" } }, "config": { @@ -56,6 +56,19 @@ "displayName": "Roles", "shape": "string" }, + { + "name": "memberships.0.resourceId", + "displayName": "projectId", + "shape": "string", + "sourceType": "Project", + "visible": false + }, + { + "name": "project", + "displayName": "Project", + "sourceId": "memberships.0.resourceId", + "objectPropertyPath": "name" + }, { "pattern": ".*" } diff --git a/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js b/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js index cf138c79..87483011 100644 --- a/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js +++ b/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js @@ -2,32 +2,26 @@ // The endpoint returns one JSON object per line (not a JSON array). // data will be null/undefined; raw text is in response.body. -const body = response.body; - -// Defensive: if the handler somehow parsed it already, handle that case -if (Array.isArray(body)) { - result = body.map(function(tx) { +result = response.body + .split("\n") + .map(function (line) { + return line.trim(); + }) + .filter(function (line) { + return line.length > 0; + }) + .map(function (line) { + return JSON.parse(line); + }) + .map(function (tx) { return { + ...tx, timestamp: tx.timestamp, transactionId: tx.id, author: tx.author, - documentCount: Array.isArray(tx.documentIDs) ? tx.documentIDs.length : 0 + documentCount: Array.isArray(tx.documentIDs) + ? tx.documentIDs.length + : 0, }; }); -} else if (typeof body === 'string') { - result = body - .split('\n') - .map(function(line) { return line.trim(); }) - .filter(function(line) { return line.length > 0; }) - .map(function(line) { return JSON.parse(line); }) - .map(function(tx) { - return { - timestamp: tx.timestamp, - transactionId: tx.id, - author: tx.author, - documentCount: Array.isArray(tx.documentIDs) ? tx.documentIDs.length : 0 - }; - }); -} else { - result = []; -} + diff --git a/plugins/Sanity/v1/dataStreams/scripts/members.js b/plugins/Sanity/v1/dataStreams/scripts/members.js index 2d0fb358..97af88f9 100644 --- a/plugins/Sanity/v1/dataStreams/scripts/members.js +++ b/plugins/Sanity/v1/dataStreams/scripts/members.js @@ -5,6 +5,7 @@ const members = (data && data.data) || []; result = members.map((user) => ({ + ...user, name: user.profile && user.profile.displayName, email: user.profile && user.profile.email, roles: (user.memberships || []) @@ -13,3 +14,4 @@ result = members.map((user) => ({ sanityUserId: user.sanityUserId, imageUrl: user.profile && user.profile.imageUrl, })); + diff --git a/plugins/Sanity/v1/defaultContent/sanityProject.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json similarity index 85% rename from plugins/Sanity/v1/defaultContent/sanityProject.dash.json rename to plugins/Sanity/v1/defaultContent/dataset.dash.json index 7391f4dc..cbfc712d 100644 --- a/plugins/Sanity/v1/defaultContent/sanityProject.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -1,8 +1,8 @@ { - "name": "Project", + "name": "Dataset", "schemaVersion": "1.5", "timeframe": "last24hours", - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "dashboard": { "_type": "layout/grid", "columns": 4, @@ -19,7 +19,7 @@ "z": 0, "config": { "_type": "tile/data-stream", - "title": "Project Details", + "title": "Dataset Details", "description": "", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -27,11 +27,11 @@ "pluginConfigId": "{{configId}}" }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "timeframe": "none", "visualisation": { "type": "data-stream-table", @@ -70,11 +70,11 @@ } }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "timeframe": "none", "visualisation": { "type": "data-stream-bar-chart", @@ -124,17 +124,22 @@ } }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "visualisation": { "type": "data-stream-table", "config": { "data-stream-table": { "transpose": false, - "columnOrder": ["timestamp", "author", "documentCount", "transactionId"] + "columnOrder": [ + "timestamp", + "author", + "documentCount", + "transactionId" + ] } } } @@ -160,11 +165,11 @@ "pluginConfigId": "{{configId}}" }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "timeframe": "none", "visualisation": { "type": "data-stream-table", @@ -197,11 +202,11 @@ "pluginConfigId": "{{configId}}" }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "timeframe": "none", "visualisation": { "type": "data-stream-table", @@ -239,11 +244,11 @@ } }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Datasets]}}", "workspace": "{{workspaceId}}", - "variable": "{{variables.[Sanity Project]}}" + "variable": "{{variables.[Dataset]}}" }, - "variables": ["{{variables.[Sanity Project]}}"], + "variables": ["{{variables.[Dataset]}}"], "timeframe": "none", "visualisation": { "type": "data-stream-table", diff --git a/plugins/Sanity/v1/defaultContent/manifest.json b/plugins/Sanity/v1/defaultContent/manifest.json index 232ef74a..26faa5dc 100644 --- a/plugins/Sanity/v1/defaultContent/manifest.json +++ b/plugins/Sanity/v1/defaultContent/manifest.json @@ -1,6 +1,6 @@ { "items": [ - { "name": "sanityProjects", "type": "dashboard" }, - { "name": "sanityProject", "type": "dashboard" } + { "name": "projects", "type": "dashboard" }, + { "name": "dataset", "type": "dashboard" } ] } diff --git a/plugins/Sanity/v1/defaultContent/sanityProjects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json similarity index 93% rename from plugins/Sanity/v1/defaultContent/sanityProjects.dash.json rename to plugins/Sanity/v1/defaultContent/projects.dash.json index 02ee7432..a6ce0c13 100644 --- a/plugins/Sanity/v1/defaultContent/sanityProjects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -19,7 +19,7 @@ "z": 0, "config": { "_type": "tile/data-stream", - "title": "Sanity Projects", + "title": "Projects", "description": "", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -27,7 +27,7 @@ "pluginConfigId": "{{configId}}" }, "scope": { - "scope": "{{scopes.[Sanity Projects]}}", + "scope": "{{scopes.[Projects]}}", "workspace": "{{workspaceId}}" }, "timeframe": "none", diff --git a/plugins/Sanity/v1/defaultContent/scopes.json b/plugins/Sanity/v1/defaultContent/scopes.json index 99d1ae43..790f1c4e 100644 --- a/plugins/Sanity/v1/defaultContent/scopes.json +++ b/plugins/Sanity/v1/defaultContent/scopes.json @@ -1,23 +1,23 @@ [ { - "name": "Sanity Projects", + "name": "Projects", "matches": { - "sourceType": { "type": "oneOf", "values": ["Sanity Project"] } + "sourceType": { "type": "oneOf", "values": ["Project"] } }, "variable": { - "name": "Sanity Project", + "name": "Project", "allowMultipleSelection": false, "default": "none", "type": "object" } }, { - "name": "Sanity Datasets", + "name": "Datasets", "matches": { - "sourceType": { "type": "oneOf", "values": ["Sanity Dataset"] } + "sourceType": { "type": "oneOf", "values": ["Dataset"] } }, "variable": { - "name": "Sanity Project", + "name": "Dataset", "allowMultipleSelection": false, "default": "none", "type": "object" diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json index dc0b2a79..147bc42a 100644 --- a/plugins/Sanity/v1/indexDefinitions/default.json +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -9,7 +9,7 @@ "objectMapping": { "id": "id", "name": "displayName", - "type": { "value": "Sanity Project" }, + "type": { "value": "Project" }, "properties": ["organizationId", "studioHost", "createdAt"] } }, @@ -19,13 +19,13 @@ "name": "datasets" }, "scope": { - "query": "g.V().has(\"sourceType\", \"Sanity Project\")" + "query": "g.V().has(\"sourceType\", \"Project\")" }, "timeframe": "none", "objectMapping": { "id": "uid", "name": "displayName", - "type": { "value": "Sanity Dataset" }, + "type": { "value": "Dataset" }, "properties": [ "datasetId", "aclMode", @@ -36,6 +36,30 @@ }, "optional": true, "dependsOn": ["projects"] + }, + { + "name": "members", + "dataStream": { + "name": "members" + }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Project\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "profile.id", + "name": "profile.displayName", + "type": { "value": "Member" }, + "properties": [ + "memberships.0.resourceId", + "roles", + "createdAt", + "createdByUserId", + "projectId" + ] + }, + "optional": true, + "dependsOn": ["projects"] } ] } diff --git a/plugins/Sanity/v1/metadata.json b/plugins/Sanity/v1/metadata.json index d7d169e3..73cc5d45 100644 --- a/plugins/Sanity/v1/metadata.json +++ b/plugins/Sanity/v1/metadata.json @@ -3,8 +3,8 @@ "displayName": "Sanity", "version": "1.0.0", "author": { - "name": "@andrewharris", - "type": "labs" + "name": "@andrewmumblebee", + "type": "community" }, "description": "Connect one or more Sanity projects: list projects, browse datasets, run GROQ queries, and track a full history of content changes — using an admin token plus per-project API tokens.", "category": "Database", @@ -13,7 +13,7 @@ "importNotSupported": false, "restrictedToPlatforms": [], "keywords": ["sanity", "cms", "headless", "groq", "content", "dataset"], - "objectTypes": ["Sanity Project"], + "objectTypes": ["Project", "Dataset"], "links": [ { "category": "documentation", From b91fd2fb9949e8ac1698bb27ba4a4bc1c1d70b4b Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 08:42:44 +0100 Subject: [PATCH 07/26] Reduce dataStreams down to useful ones --- .../Sanity/v1/dataStreams/changeHistory.json | 75 ---------- .../Sanity/v1/dataStreams/datasetStats.json | 12 +- .../v1/dataStreams/documentTypeCounts.json | 51 ------- .../v1/dataStreams/scripts/changeHistory.js | 27 ---- .../dataStreams/scripts/documentTypeCounts.js | 7 - .../v1/defaultContent/dataset.dash.json | 132 +----------------- .../v1/defaultContent/projects.dash.json | 76 +++++++++- plugins/Sanity/v1/docs/README.md | 48 ++++--- .../Sanity/v1/indexDefinitions/default.json | 2 +- plugins/Sanity/v1/metadata.json | 4 +- plugins/Sanity/v1/ui.json | 6 +- 11 files changed, 114 insertions(+), 326 deletions(-) delete mode 100644 plugins/Sanity/v1/dataStreams/changeHistory.json delete mode 100644 plugins/Sanity/v1/dataStreams/documentTypeCounts.json delete mode 100644 plugins/Sanity/v1/dataStreams/scripts/changeHistory.js delete mode 100644 plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js diff --git a/plugins/Sanity/v1/dataStreams/changeHistory.json b/plugins/Sanity/v1/dataStreams/changeHistory.json deleted file mode 100644 index be719692..00000000 --- a/plugins/Sanity/v1/dataStreams/changeHistory.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "name": "changeHistory", - "displayName": "Change History", - "description": "Transaction history for a dataset — every content change with timestamp, author, and affected document count", - "tags": ["Documents"], - "baseDataSourceName": "httpRequestScopedSingle", - "matches": { - "sourceType": { - "type": "equals", - "value": "Dataset" - } - }, - "config": { - "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", - "httpMethod": "get", - "paging": { - "mode": "none" - }, - "endpointPath": "data/history/{{object.datasetId}}/transactions", - "getArgs": [ - { "key": "excludeContent", "value": "true" }, - { "key": "reverse", "value": "true" }, - { "key": "limit", "value": "100" }, - { "key": "fromTime", "value": "{{timeframe.start}}" }, - { "key": "toTime", "value": "{{timeframe.end}}" } - ], - "headers": [ - { - "key": "Authorization", - "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" - } - ], - "postRequestScript": "changeHistory.js" - }, - - "metadata": [ - { - "name": "timestamp", - "displayName": "Time", - "shape": "date", - "role": "timestamp" - }, - { - "name": "transactionId", - "displayName": "Transaction ID", - "shape": "string", - "role": "id" - }, - { - "name": "author", - "displayName": "Author", - "shape": "string", - "role": "label" - }, - { - "name": "documentCount", - "displayName": "Documents Changed", - "shape": "number", - "role": "value" - }, - { - "pattern": ".*" - } - ], - "timeframes": [ - "last1hour", - "last12hours", - "last24hours", - "last7days", - "last30days", - "lastMonth", - "lastQuarter", - "lastYear" - ] -} diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json index 7e10783d..be6e91a7 100644 --- a/plugins/Sanity/v1/dataStreams/datasetStats.json +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -10,7 +10,6 @@ "value": "Dataset" } }, - "providesPluginDiagnostics": true, "config": { "baseUrl": "https://{{object.projectId}}.api.sanity.io/v1/", "httpMethod": "get", @@ -27,19 +26,10 @@ ], "expandInnerObjects": true }, - "ui": [ - { - "type": "text", - "name": "hostname", - "label": "Hostname", - "placeholder": "api.example.com" - } - ], "metadata": [ { "pattern": ".*" } ], - "timeframes": false, - "manualConfigApply": true + "timeframes": false } diff --git a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json b/plugins/Sanity/v1/dataStreams/documentTypeCounts.json deleted file mode 100644 index 8fbe48cc..00000000 --- a/plugins/Sanity/v1/dataStreams/documentTypeCounts.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "documentTypeCounts", - "displayName": "Document Type Counts", - "description": "Count of published documents per type in a dataset", - "tags": ["Documents"], - "baseDataSourceName": "httpRequestScopedSingle", - "matches": { - "sourceType": { - "type": "equals", - "value": "Dataset" - } - }, - "config": { - "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", - "httpMethod": "get", - "paging": { - "mode": "none" - }, - "endpointPath": "data/query/{{object.datasetId}}", - "getArgs": [ - { - "key": "query", - "value": "*[!(_id in path(\"drafts.**\"))]._type" - } - ], - "headers": [ - { - "key": "Authorization", - "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" - } - ], - "postRequestScript": "documentTypeCounts.js" - }, - "ui": [], - "metadata": [ - { - "name": "documentType", - "displayName": "Document Type", - "shape": "string", - "role": "label" - }, - { - "name": "count", - "displayName": "Count", - "shape": "number", - "role": "value" - } - ], - "timeframes": false, - "manualConfigApply": true -} diff --git a/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js b/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js deleted file mode 100644 index 87483011..00000000 --- a/plugins/Sanity/v1/dataStreams/scripts/changeHistory.js +++ /dev/null @@ -1,27 +0,0 @@ -// Parse NDJSON response from Sanity history/transactions endpoint. -// The endpoint returns one JSON object per line (not a JSON array). -// data will be null/undefined; raw text is in response.body. - -result = response.body - .split("\n") - .map(function (line) { - return line.trim(); - }) - .filter(function (line) { - return line.length > 0; - }) - .map(function (line) { - return JSON.parse(line); - }) - .map(function (tx) { - return { - ...tx, - timestamp: tx.timestamp, - transactionId: tx.id, - author: tx.author, - documentCount: Array.isArray(tx.documentIDs) - ? tx.documentIDs.length - : 0, - }; - }); - diff --git a/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js b/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js deleted file mode 100644 index 00e3cd92..00000000 --- a/plugins/Sanity/v1/dataStreams/scripts/documentTypeCounts.js +++ /dev/null @@ -1,7 +0,0 @@ -// data.result is an array of _type strings for all published documents. -// Use lodash countBy to tally occurrences, then emit one row per type. -const counts = _.countBy(data.result || []); -result = Object.entries(counts).map(([documentType, count]) => ({ - documentType, - count, -})); diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json index cbfc712d..cb79f5b9 100644 --- a/plugins/Sanity/v1/defaultContent/dataset.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -58,15 +58,12 @@ "description": "", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "{{dataStreams.[documentTypeCounts]}}", - "name": "documentTypeCounts", + "id": "{{dataStreams.[customQuery]}}", + "name": "customQuery", "pluginConfigId": "{{configId}}", "dataSourceConfig": { - "dataset": "production" - }, - "sort": { - "by": [["count", "desc"]], - "top": 20 + "query": "*[ _id == *[_type == ^._type][0]._id ]{ \"documentType\": _type, \"count\": count(*[_type == ^._type]) } | order(count desc) [0...20]", + "perspective": "published" } }, "scope": { @@ -101,128 +98,10 @@ } } }, - { - "i": "f4d3e130-21b5-4575-bcd9-05460086200b", - "x": 0, - "y": 4, - "w": 4, - "h": 4, - "moved": false, - "static": false, - "z": 0, - "config": { - "_type": "tile/data-stream", - "title": "Recent Changes", - "description": "", - "activePluginConfigIds": ["{{configId}}"], - "dataStream": { - "id": "{{dataStreams.[changeHistory]}}", - "name": "changeHistory", - "pluginConfigId": "{{configId}}", - "dataSourceConfig": { - "dataset": "production" - } - }, - "scope": { - "scope": "{{scopes.[Datasets]}}", - "workspace": "{{workspaceId}}", - "variable": "{{variables.[Dataset]}}" - }, - "variables": ["{{variables.[Dataset]}}"], - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": [ - "timestamp", - "author", - "documentCount", - "transactionId" - ] - } - } - } - } - }, - { - "i": "4ded0fa6-3ac1-4b0a-af03-123928006951", - "x": 0, - "y": 8, - "w": 2, - "h": 4, - "moved": false, - "static": false, - "z": 0, - "config": { - "_type": "tile/data-stream", - "title": "Datasets", - "description": "", - "activePluginConfigIds": ["{{configId}}"], - "dataStream": { - "id": "{{dataStreams.[datasets]}}", - "name": "datasets", - "pluginConfigId": "{{configId}}" - }, - "scope": { - "scope": "{{scopes.[Datasets]}}", - "workspace": "{{workspaceId}}", - "variable": "{{variables.[Dataset]}}" - }, - "variables": ["{{variables.[Dataset]}}"], - "timeframe": "none", - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["name", "aclMode"] - } - } - } - } - }, - { - "i": "cb6c1ad8-25f4-4e50-b5c9-619f1cadd6f8", - "x": 2, - "y": 8, - "w": 2, - "h": 4, - "moved": false, - "static": false, - "z": 0, - "config": { - "_type": "tile/data-stream", - "title": "Members", - "description": "", - "activePluginConfigIds": ["{{configId}}"], - "dataStream": { - "id": "{{dataStreams.[members]}}", - "name": "members", - "pluginConfigId": "{{configId}}" - }, - "scope": { - "scope": "{{scopes.[Datasets]}}", - "workspace": "{{workspaceId}}", - "variable": "{{variables.[Dataset]}}" - }, - "variables": ["{{variables.[Dataset]}}"], - "timeframe": "none", - "visualisation": { - "type": "data-stream-table", - "config": { - "data-stream-table": { - "transpose": false, - "columnOrder": ["name", "email", "roles"] - } - } - } - } - }, { "i": "b36972d8-2fa5-46fa-987b-f811ffa53ff2", "x": 0, - "y": 12, + "y": 4, "w": 4, "h": 4, "moved": false, @@ -238,7 +117,6 @@ "name": "customQuery", "pluginConfigId": "{{configId}}", "dataSourceConfig": { - "dataset": "production", "query": "*[0...20]{_id, _type, _updatedAt}", "perspective": "published" } diff --git a/plugins/Sanity/v1/defaultContent/projects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json index a6ce0c13..b5ca26db 100644 --- a/plugins/Sanity/v1/defaultContent/projects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -2,7 +2,7 @@ "name": "Projects", "schemaVersion": "1.5", "timeframe": "last24hours", - "variables": [], + "variables": ["{{variables.[Project]}}"], "dashboard": { "_type": "layout/grid", "columns": 4, @@ -43,6 +43,80 @@ } } } + }, + { + "i": "4ded0fa6-3ac1-4b0a-af03-123928006951", + "x": 0, + "y": 4, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Datasets", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasets]}}", + "name": "datasets", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Project]}}" + }, + "variables": ["{{variables.[Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "aclMode"] + } + } + } + } + }, + { + "i": "cb6c1ad8-25f4-4e50-b5c9-619f1cadd6f8", + "x": 2, + "y": 4, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Members", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[members]}}", + "name": "members", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Project]}}" + }, + "variables": ["{{variables.[Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["name", "email", "roles"] + } + } + } + } } ] } diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index 9787395f..c1a39ae2 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -1,36 +1,37 @@ # Sanity -Bring your [Sanity](https://www.sanity.io) content into SquaredUp. A single plugin instance can cover **many Sanity projects** at once: it lists the projects your admin token can see, and — for each project you supply an API token for — lets you browse datasets, run GROQ queries, break content down by document type, and see a full **history of changes**. +Bring your [Sanity](https://www.sanity.io) content into SquaredUp. A single plugin instance can cover **many Sanity projects** at once: it indexes the projects your admin token can see — plus each project's datasets and members — and lets you run GROQ queries, break content down by document type, and watch dataset usage against plan limits. ## What this plugin does -You give the plugin two things: an **admin token** (used to list projects, datasets and members across your organization) and a set of **per-project API tokens** (used to query each project's content). It then imports: +You give the plugin two things: an **admin token** (used to list the projects in your organization) and a set of **per-project API tokens** (used to read each project's datasets, members and content). It then imports: -- **Each project** your admin token can see, as a `Sanity Project` object you can scope dashboards to and drill into. +- **Each project** your admin token can see, as a `Project` object. +- **Each dataset** in the projects you've supplied a token for, as a `Dataset` object you can scope dashboards to and drill into. +- **Each member** of those projects, as a `Member` object. From there you get data streams for: -- **Change history** — a project/dataset's transaction log: who changed what and when, over any dashboard timeframe. -- **Document type counts** — how many documents of each `_type` live in a dataset. -- **Custom GROQ query** — run any GROQ query against a dataset straight from a tile. +- **Custom GROQ query** — run any GROQ query against a dataset straight from a tile (the out-of-the-box dashboard uses this to break a dataset down by document type). +- **Dataset stats** — a dataset's usage figures and how close it is to plan limits. - **Datasets** — a table of a project's datasets and their access modes. -- **Members** (optional) — the people with access to a project and their roles. +- **Members** — the people with access to a project and their roles. ## Why two kinds of token? Sanity uses two API surfaces: -- **Management** (`api.sanity.io`) — listing projects, datasets and members. The plugin uses your **admin token** here. -- **Content** (`.api.sanity.io`) — GROQ queries and history. The plugin uses the **per-project token** matching the project being queried. +- **Management** (`api.sanity.io`) — the plugin uses your **admin token** to list projects, and the matching **per-project token** to list each project's datasets and members. +- **Content** (`.api.sanity.io`) — GROQ queries and dataset stats. The plugin uses the **per-project token** matching the project being queried. -This keeps content access least-privilege: each project's token can only read that project. The query and history tiles work for any project you've added a token for; projects without a token still appear (with their datasets/members via the admin token) but their query/history tiles will show an authorization error. +This keeps content access least-privilege: each project's token can only read that project. Datasets, members and content tiles work for any project you've added a token for; projects without a token still appear as objects, but only the project itself — no datasets, members or content. ## Prerequisites — getting your credentials ### Admin API token 1. Sign in to [sanity.io/manage](https://www.sanity.io/manage). -2. Choose an organization-level or project token with enough access to list the projects you care about. A **personal token** (from an admin account) or an organization token works well. To also populate the optional **Members** tile, the token needs member-read access (an Administrator/personal token). +2. Choose an organization-level or project token with enough access to list the projects you care about. A **personal token** (from an admin account) or an organization token works well. 3. If creating a project token: open a project → **API → Tokens → Add API token**, and copy the value (shown once). ### Project IDs and per-project tokens @@ -47,25 +48,30 @@ For **each** project you want to query content in: | Field | What it is | Where to find it | Required | |---|---|---|---| -| **Admin API token** | Token used for management calls (listing projects, datasets, members) | sanity.io/manage → API → Tokens (or a personal/admin token) | Yes | -| **Project API tokens** | A list of **Project ID → API token** pairs, one per project you want to query | Project ID from the project page; Viewer token from API → Tokens | Yes | -| **Content API version** | The dated Sanity API version used for GROQ/history (advanced) | Defaults to `v2025-02-19`; leave as-is unless you need a specific version | No | +| **Admin API token** | Token used to list the projects in your organization | sanity.io/manage → API → Tokens (or a personal/admin token) | Yes | +| **Project API tokens** | A list of **Project ID → API token** pairs, one per project you want to read datasets, members and content from | Project ID from the project page; Viewer token from API → Tokens | Yes | +| **Content API version** | The dated Sanity API version used for GROQ queries (advanced) | Defaults to `v2025-02-19`; leave as-is unless you need a specific version | No | ## What gets indexed | Object type | Represents | Example | |---|---|---| -| **Sanity Project** | A project your admin token can see | `My Studio (abc12xyz)` | +| **Project** | A project your admin token can see | `My Studio` | +| **Dataset** | A dataset in a project you've supplied a token for | `My Studio - production` | +| **Member** | A user with access to one of those projects | `Jane Doe` | -Datasets are **not** indexed as objects (Sanity has no cross-project datasets endpoint). Instead, each project's datasets appear as a table, and you choose a dataset via the **dataset** parameter on the query, history and document-count tiles. +Dataset and member imports are **optional steps**: projects without a per-project token simply don't contribute datasets or members, and the rest of the import carries on. + +## Out-of-the-box dashboards + +- **Projects** — all the projects the plugin can see. +- **Dataset** — a per-dataset drilldown (pick the dataset via the dashboard variable): details, document type breakdown, datasets and members of the owning project, and a customisable GROQ query tile. ## Known limitations -- **No usage or billing metrics.** Sanity's HTTP API exposes no API-request counts, bandwidth, storage, or plan/quota figures. -- **Content tiles need a per-project token.** Query, history and document-count tiles for a project only work if you've added that project's token under **Project API tokens**. -- **Document type counts read document metadata.** The breakdown comes from a GROQ query returning each document's `_type`; on very large datasets it can be slow — scope it to specific types if needed. -- **Change history depth.** The transaction log is fetched per dashboard timeframe with a capped number of transactions per request; very busy windows may be truncated to the most recent transactions. -- **Members tile permissions.** Requires the admin token to have member-read access; otherwise the tile shows an authorization error and nothing else is affected. +- **No organization-level usage or billing metrics.** Sanity's HTTP API exposes no API-request counts, bandwidth, or billing figures; the **Dataset stats** stream covers per-dataset usage against plan limits. +- **Content tiles need a per-project token.** Datasets, members, GROQ query and dataset stats only work for projects you've added under **Project API tokens**. +- **Document type counts can be slow on large datasets.** The out-of-the-box breakdown tile groups every document by `_type` in GROQ; on very large datasets it can be slow — narrow the query to specific types if needed. ## Useful links diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json index 147bc42a..fb367ad2 100644 --- a/plugins/Sanity/v1/indexDefinitions/default.json +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -47,7 +47,7 @@ }, "timeframe": "none", "objectMapping": { - "id": "profile.id", + "id": "sanityUserId", "name": "profile.displayName", "type": { "value": "Member" }, "properties": [ diff --git a/plugins/Sanity/v1/metadata.json b/plugins/Sanity/v1/metadata.json index 73cc5d45..968271d0 100644 --- a/plugins/Sanity/v1/metadata.json +++ b/plugins/Sanity/v1/metadata.json @@ -6,14 +6,14 @@ "name": "@andrewmumblebee", "type": "community" }, - "description": "Connect one or more Sanity projects: list projects, browse datasets, run GROQ queries, and track a full history of content changes — using an admin token plus per-project API tokens.", + "description": "Connect one or more Sanity projects: index projects, datasets and members, run GROQ queries, and watch dataset usage against plan limits — using an admin token plus per-project API tokens.", "category": "Database", "type": "hybrid", "schemaVersion": "2.1", "importNotSupported": false, "restrictedToPlatforms": [], "keywords": ["sanity", "cms", "headless", "groq", "content", "dataset"], - "objectTypes": ["Project", "Dataset"], + "objectTypes": ["Project", "Dataset", "Member"], "links": [ { "category": "documentation", diff --git a/plugins/Sanity/v1/ui.json b/plugins/Sanity/v1/ui.json index a37a3b2a..412b30a2 100644 --- a/plugins/Sanity/v1/ui.json +++ b/plugins/Sanity/v1/ui.json @@ -7,7 +7,7 @@ "required": true }, "placeholder": "Enter an organization or admin API token", - "help": "Used for management calls — listing projects, datasets and members. Use an organization token or a personal token from an admin account. To populate the optional Members tile this token needs member-read access (Administrator/personal). Create tokens in [sanity.io/manage](https://www.sanity.io/manage) → your project → API → Tokens." + "help": "Used to list the projects in your organization. Use an organization token or a personal token from an admin account. Create tokens in [sanity.io/manage](https://www.sanity.io/manage) → your project → API → Tokens." }, { "type": "key-value", @@ -29,7 +29,7 @@ "required": true } }, - "help": "For each Sanity project you want to query, add its **Project ID** and a project API token (Viewer role). The Project ID is shown on the project page in [sanity.io/manage](https://www.sanity.io/manage); create a token under that project's API → Tokens. Query and history tiles only work for projects listed here." + "help": "For each Sanity project you want to query, add its **Project ID** and a project API token (Viewer role). The Project ID is shown on the project page in [sanity.io/manage](https://www.sanity.io/manage); create a token under that project's API → Tokens. Datasets, members and content tiles only work for projects listed here." }, { "type": "text", @@ -37,6 +37,6 @@ "label": "Content API version", "defaultValue": "v2025-02-19", "placeholder": "v2025-02-19", - "help": "Advanced: the dated Sanity API version used for GROQ queries and change history. Leave as the default unless you need a specific version." + "help": "Advanced: the dated Sanity API version used for GROQ queries. Leave as the default unless you need a specific version." } ] From b1535bd908e4a96ef337521a1a77bd53675d490e Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 09:00:45 +0100 Subject: [PATCH 08/26] Show recently updated documents in the GROQ tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Custom GROQ Query tile (and the stream's default query) previously fetched an arbitrary unordered slice of 20 documents, which surfaced whatever happened to come back first — including internal system.group documents. Order by _updatedAt descending before slicing and exclude system documents (ids under the "_." path) so the starting query shows the 20 most recently updated content documents, a far more useful default for content editors. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/customQuery.json | 2 +- plugins/Sanity/v1/defaultContent/dataset.dash.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json index adcd32f2..d0818acd 100644 --- a/plugins/Sanity/v1/dataStreams/customQuery.json +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -43,7 +43,7 @@ "name": "query", "language": "groq", "label": "GROQ query", - "defaultValue": "*[0...20]{_id, _type, _updatedAt}", + "defaultValue": "*[ !(_id in path(\"_.**\")) ] | order(_updatedAt desc) [0...20] {_id, _type, _updatedAt}", "validation": { "required": true }, "help": "See [GROQ query cheat sheet](https://www.sanity.io/docs/content-lake/query-cheat-sheet) for patterns" }, diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json index cb79f5b9..29886337 100644 --- a/plugins/Sanity/v1/defaultContent/dataset.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -110,14 +110,14 @@ "config": { "_type": "tile/data-stream", "title": "Custom GROQ Query", - "description": "Edit the tile to customise the GROQ query and dataset.", + "description": "Explore the tile to customise the GROQ query and dataset.", "activePluginConfigIds": ["{{configId}}"], "dataStream": { "id": "{{dataStreams.[customQuery]}}", "name": "customQuery", "pluginConfigId": "{{configId}}", "dataSourceConfig": { - "query": "*[0...20]{_id, _type, _updatedAt}", + "query": "*[ !(_id in path(\"_.**\")) ] | order(_updatedAt desc) [0...20] {_id, _type, _updatedAt}", "perspective": "published" } }, From 328dcfda03bdf9e1ae6d5e8118c582ac2045cdc6 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 09:01:11 +0100 Subject: [PATCH 09/26] Show dataset limit usage on the Dataset dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The datasetStats stream returned Sanity's raw value/limit pairs, which made it hard to see at a glance how close a dataset is to its plan limits — the numbers needed manual arithmetic and the byte counts were unformatted. A post-request script now computes percent-of-limit columns for documents, fields, JSON size and releases (null when the limit is 0, which Sanity uses to mean "no limit"), stamps each row with the dataset's object name so multi-dataset tiles can tell rows apart, and explicit metadata formats the key columns as percentages and bytes. The Dataset dashboard gains a stats row built on these columns: three gauges (documents, fields and JSON size as % of limit) and a document count scalar. Verified against a real dataset sitting at 98% of its fields limit, which is exactly the situation these tiles exist to surface. Co-Authored-By: Claude Fable 5 --- .../Sanity/v1/dataStreams/datasetStats.json | 60 ++++++- .../v1/dataStreams/scripts/datasetStats.js | 18 ++ .../v1/defaultContent/dataset.dash.json | 163 +++++++++++++++++- 3 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 plugins/Sanity/v1/dataStreams/scripts/datasetStats.js diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json index be6e91a7..c97a80be 100644 --- a/plugins/Sanity/v1/dataStreams/datasetStats.json +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -24,9 +24,67 @@ "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" } ], - "expandInnerObjects": true + "expandInnerObjects": true, + "postRequestScript": "datasetStats.js" }, "metadata": [ + { + "name": "dataset", + "displayName": "Dataset", + "shape": "string", + "role": "label" + }, + { + "name": "documentsPctOfLimit", + "displayName": "Documents (% of limit)", + "shape": "percent", + "role": "value" + }, + { + "name": "fieldsPctOfLimit", + "displayName": "Fields (% of limit)", + "shape": "percent" + }, + { + "name": "jsonSizePctOfLimit", + "displayName": "JSON Size (% of limit)", + "shape": "percent" + }, + { + "name": "releasesPctOfLimit", + "displayName": "Releases (% of limit)", + "shape": "percent" + }, + { + "name": "documents.count.value", + "displayName": "Documents", + "shape": "number" + }, + { + "name": "documents.count.limit", + "displayName": "Document Limit", + "shape": "number" + }, + { + "name": "fields.count.value", + "displayName": "Fields", + "shape": "number" + }, + { + "name": "fields.count.limit", + "displayName": "Field Limit", + "shape": "number" + }, + { + "name": "documents.jsonSizeSum.value", + "displayName": "JSON Size", + "shape": "bytes" + }, + { + "name": "documents.jsonSizeSum.limit", + "displayName": "JSON Size Limit", + "shape": "bytes" + }, { "pattern": ".*" } diff --git a/plugins/Sanity/v1/dataStreams/scripts/datasetStats.js b/plugins/Sanity/v1/dataStreams/scripts/datasetStats.js new file mode 100644 index 00000000..ac837edf --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/datasetStats.js @@ -0,0 +1,18 @@ +// data is the stats object: { documents: { count, sizeSum, jsonSizeSum }, fields, types, releases, ... } +// where each metric is { value, limit, unit }. A limit of 0 means "no limit" — emit null +// so percent columns stay empty instead of dividing by zero. +const pctOfLimit = (metric) => + metric && metric.limit > 0 ? (metric.value / metric.limit) * 100 : null; + +const object = context.objects[0]; + +result = [ + { + ...data, + dataset: object?.name, + documentsPctOfLimit: pctOfLimit(data.documents?.count), + jsonSizePctOfLimit: pctOfLimit(data.documents?.jsonSizeSum), + fieldsPctOfLimit: pctOfLimit(data.fields?.count), + releasesPctOfLimit: pctOfLimit(data.releases?.count), + }, +]; diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json index 29886337..c94b691b 100644 --- a/plugins/Sanity/v1/defaultContent/dataset.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -99,9 +99,170 @@ } }, { - "i": "b36972d8-2fa5-46fa-987b-f811ffa53ff2", + "i": "a6f17e01-0056-40b1-9f3c-ff21b641b699", "x": 0, "y": 4, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Documents (% of limit)", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Dataset]}}" + }, + "variables": ["{{variables.[Dataset]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-gauge", + "config": { + "data-stream-gauge": { + "value": { + "type": "mean", + "columns": ["documentsPctOfLimit"] + }, + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + { + "i": "85ac22b9-74bc-4809-a896-693090452b76", + "x": 1, + "y": 4, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Fields (% of limit)", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Dataset]}}" + }, + "variables": ["{{variables.[Dataset]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-gauge", + "config": { + "data-stream-gauge": { + "value": { + "type": "mean", + "columns": ["fieldsPctOfLimit"] + }, + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + { + "i": "45260163-0c65-45d7-bb65-980d57baf7a3", + "x": 2, + "y": 4, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "JSON Size (% of limit)", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Dataset]}}" + }, + "variables": ["{{variables.[Dataset]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-gauge", + "config": { + "data-stream-gauge": { + "value": { + "type": "mean", + "columns": ["jsonSizePctOfLimit"] + }, + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + { + "i": "c7a8e23a-4258-43f9-af78-c3a78bca04b5", + "x": 3, + "y": 4, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Documents", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Dataset]}}" + }, + "variables": ["{{variables.[Dataset]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "documents.count.value", + "comparisonColumn": "none", + "label": "documents" + } + } + } + } + }, + { + "i": "b36972d8-2fa5-46fa-987b-f811ffa53ff2", + "x": 0, + "y": 6, "w": 4, "h": 4, "moved": false, From bb5984e892b41b60ee0f1c0d0325abb9feca273d Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 09:01:25 +0100 Subject: [PATCH 10/26] Add an Overview dashboard summarising the org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Projects dashboard was doing two jobs at once: it listed every project while its other tiles were scoped to a single project via the dashboard variable, which made the page read oddly. A new Overview dashboard now owns the org-wide view — project, dataset and document counts, total JSON stored, the all-projects blocks list, documents by dataset, and a per-dataset limit-usage table sorted so the datasets closest to a plan limit surface first. The layout follows the KPI-row- then-charts shape used by other plugins' overview dashboards. The Projects dashboard becomes a pure per-project drilldown (details, datasets, members) and Overview is first in the manifest so it's the landing page. Dataset-derived tiles only cover projects with a configured per-project token, since datasets of untokened projects are never imported as objects. Co-Authored-By: Claude Fable 5 --- .../Sanity/v1/defaultContent/manifest.json | 1 + .../v1/defaultContent/overview.dash.json | 312 ++++++++++++++++++ .../v1/defaultContent/projects.dash.json | 27 +- plugins/Sanity/v1/docs/README.md | 5 +- 4 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 plugins/Sanity/v1/defaultContent/overview.dash.json diff --git a/plugins/Sanity/v1/defaultContent/manifest.json b/plugins/Sanity/v1/defaultContent/manifest.json index 26faa5dc..2f5d1fc8 100644 --- a/plugins/Sanity/v1/defaultContent/manifest.json +++ b/plugins/Sanity/v1/defaultContent/manifest.json @@ -1,5 +1,6 @@ { "items": [ + { "name": "overview", "type": "dashboard" }, { "name": "projects", "type": "dashboard" }, { "name": "dataset", "type": "dashboard" } ] diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json new file mode 100644 index 00000000..a1c6ad38 --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -0,0 +1,312 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "2b4c4986-e339-4565-b8b4-b9d35fb33c46", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Projects", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[listProjects]}}", + "name": "listProjects", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "projects" + } + } + } + } + }, + { + "i": "90206f76-3975-4a65-9c93-b452b19331a0", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Datasets", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "datasets" + } + } + } + } + }, + { + "i": "4f9fef20-3a30-425f-b119-16e796901d7c", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Documents", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "sum", + "names": ["documents.count.value"] + } + ] + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "documents.count.value_sum", + "comparisonColumn": "none", + "label": "documents" + } + } + } + } + }, + { + "i": "d5b7371a-9142-4ff5-9996-a3d18a7b23eb", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "JSON Stored", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}", + "group": { + "by": [], + "aggregate": [ + { + "type": "sum", + "names": ["documents.jsonSizeSum.value"] + } + ] + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "documents.jsonSizeSum.value_sum", + "comparisonColumn": "none", + "label": "JSON stored" + } + } + } + } + }, + { + "i": "9e3a54e7-80c9-42df-b8fc-94decb1c5505", + "x": 0, + "y": 2, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Projects", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Projects]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-blocks", + "config": { + "data-stream-blocks": { + "labelColumn": "name", + "stateColumn": "none", + "linkColumn": "name", + "columns": 4 + } + } + } + } + }, + { + "i": "b559b8ba-3a0a-4307-8b1b-b0351902a729", + "x": 0, + "y": 6, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Documents by Dataset", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["documents.count.value", "desc"]], + "top": 20 + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "dataset", + "yAxisData": ["documents.count.value"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Documents", + "showXAxisLabel": true, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": false, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "147db02e-9f70-4db9-bff2-fd8264cd5921", + "x": 2, + "y": 6, + "w": 2, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Dataset Limit Usage", + "description": "How close each dataset is to its plan limits.", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[datasetStats]}}", + "name": "datasetStats", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["fieldsPctOfLimit", "desc"]] + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "dataset", + "fieldsPctOfLimit", + "documentsPctOfLimit", + "jsonSizePctOfLimit", + "releasesPctOfLimit" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Sanity/v1/defaultContent/projects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json index b5ca26db..bb9ea6cf 100644 --- a/plugins/Sanity/v1/defaultContent/projects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -12,14 +12,14 @@ "i": "9e3a54e7-80c9-42df-b8fc-94decb1c5505", "x": 0, "y": 0, - "w": 4, + "w": 1, "h": 4, "moved": false, "static": false, "z": 0, "config": { "_type": "tile/data-stream", - "title": "Projects", + "title": "Project Details", "description": "", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -28,17 +28,16 @@ }, "scope": { "scope": "{{scopes.[Projects]}}", - "workspace": "{{workspaceId}}" + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Project]}}" }, + "variables": ["{{variables.[Project]}}"], "timeframe": "none", "visualisation": { - "type": "data-stream-blocks", + "type": "data-stream-table", "config": { - "data-stream-blocks": { - "labelColumn": "name", - "stateColumn": "none", - "linkColumn": "name", - "columns": 4 + "data-stream-table": { + "transpose": true } } } @@ -46,9 +45,9 @@ }, { "i": "4ded0fa6-3ac1-4b0a-af03-123928006951", - "x": 0, - "y": 4, - "w": 2, + "x": 1, + "y": 0, + "w": 3, "h": 4, "moved": false, "static": false, @@ -83,9 +82,9 @@ }, { "i": "cb6c1ad8-25f4-4e50-b5c9-619f1cadd6f8", - "x": 2, + "x": 0, "y": 4, - "w": 2, + "w": 4, "h": 4, "moved": false, "static": false, diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index c1a39ae2..eac25440 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -64,8 +64,9 @@ Dataset and member imports are **optional steps**: projects without a per-projec ## Out-of-the-box dashboards -- **Projects** — all the projects the plugin can see. -- **Dataset** — a per-dataset drilldown (pick the dataset via the dashboard variable): details, document type breakdown, datasets and members of the owning project, and a customisable GROQ query tile. +- **Overview** — organization-wide summary: project, dataset and document counts, total JSON stored, all projects at a glance, documents by dataset, and how close each dataset is to its plan limits. +- **Projects** — a per-project drilldown (pick the project via the dashboard variable): project details, its datasets and its members. +- **Dataset** — a per-dataset drilldown (pick the dataset via the dashboard variable): details, document type breakdown, usage gauges showing how close the dataset is to its plan limits, and a customisable GROQ query tile. ## Known limitations From edcbddc547054c6df3f4ea973ee8f7b997483fe3 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 09:25:46 +0100 Subject: [PATCH 11/26] Explain the missing token when content tiles fail When a per-project API token was missing or wrong, token-dependent tiles surfaced Sanity's raw response ("Invalid authorization header - please double-check token"), which says nothing about which project is affected or how to fix it. The four content streams now carry an errorHandling script that names the project and points at the Project API tokens setting when no token is configured for it. The token-presence check is resolved at request time via template substitution, since the script sandbox itself cannot read the data source config. When a token is configured and the request still fails, the script falls back to Sanity's own error message (so GROQ syntax errors etc. come through verbatim) and then to the HTTP status. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/customQuery.json | 4 ++++ plugins/Sanity/v1/dataStreams/datasetStats.json | 4 ++++ plugins/Sanity/v1/dataStreams/datasets.json | 4 ++++ plugins/Sanity/v1/dataStreams/members.json | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/plugins/Sanity/v1/dataStreams/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json index d0818acd..9a56603b 100644 --- a/plugins/Sanity/v1/dataStreams/customQuery.json +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -14,6 +14,10 @@ "config": { "baseUrl": "https://{{object.projectId}}.api.sanity.io/{{dataSource.apiVersion}}/", "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.projectId}} (which this dataset belongs to). Add its Project ID and API token under Project API tokens in the Sanity data source settings to query it.';" + }, "paging": { "mode": "none" }, diff --git a/plugins/Sanity/v1/dataStreams/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json index c97a80be..73fc73c2 100644 --- a/plugins/Sanity/v1/dataStreams/datasetStats.json +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -13,6 +13,10 @@ "config": { "baseUrl": "https://{{object.projectId}}.api.sanity.io/v1/", "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.projectId}} (which this dataset belongs to). Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its usage stats.';" + }, "paging": { "mode": "none" }, diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index e5e0b9b7..96f0f9d3 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -12,6 +12,10 @@ }, "config": { "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.name}}. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its datasets.';" + }, "paging": { "mode": "none" }, diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index ebab5681..4ff67594 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -13,6 +13,10 @@ "config": { "baseUrl": "https://api.sanity.io/v2025-07-11/", "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.name}}. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its members.';" + }, "endpointPath": "access/project/{{object.rawId}}/users", "getArgs": [], "paging": { From 76ca3db94ced8c8b5ec8aa80982ef0218aeda061 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 09:26:00 +0100 Subject: [PATCH 12/26] Index only projects with a configured token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously every project the admin token could see was imported, so users could scope dashboards to projects the plugin had no content token for — objects that could never show dataset, member or content data. The listProjects stream (which backs the project import and config validation) now filters the management API response down to the projects configured under Project API tokens, so everything in the graph is actually queryable. Dropping the admin token entirely was considered, deriving projects purely from the configured token list, but the admin listing is what supplies project display names and creation dates, so it stays. The filter works because the post-request script context exposes the data source config (token values encrypted, project-ID keys readable). With untokened projects gone from the graph, the "requires a token" tile captions and Overview caveats added earlier no longer describe a reachable state and are removed; the Overview projects tiles instead note that only tokened projects are indexed, since the count no longer matches the organization's full project list. Existing untokened Project objects are pruned on the next import. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/listProjects.json | 5 +++-- .../Sanity/v1/dataStreams/scripts/listProjects.js | 9 +++++++++ .../Sanity/v1/defaultContent/overview.dash.json | 2 +- plugins/Sanity/v1/docs/README.md | 14 +++++++------- plugins/Sanity/v1/ui.json | 2 +- 5 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 plugins/Sanity/v1/dataStreams/scripts/listProjects.js diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json index bd5bfc8d..971a37cc 100644 --- a/plugins/Sanity/v1/dataStreams/listProjects.json +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -1,7 +1,7 @@ { "name": "listProjects", "displayName": "Projects", - "description": "Lists all projects the admin token can access. Backs config validation and the project import.", + "description": "Lists the projects that have a content API token configured. Backs config validation and the project import.", "tags": ["Project"], "baseDataSourceName": "httpRequestUnscoped", "config": { @@ -12,7 +12,8 @@ "expandInnerObjects": false, "endpointPath": "projects", "getArgs": [], - "headers": [] + "headers": [], + "postRequestScript": "listProjects.js" }, "metadata": [ { diff --git a/plugins/Sanity/v1/dataStreams/scripts/listProjects.js b/plugins/Sanity/v1/dataStreams/scripts/listProjects.js new file mode 100644 index 00000000..1d4f2895 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/listProjects.js @@ -0,0 +1,9 @@ +// Only return projects the user has configured a content API token for. +// Projects without a token can't serve any data streams, so indexing them +// would let users scope dashboards to objects that only show project details, no dataset/member data. +// Token values arrive encrypted here, but the keys (project IDs) are usable. +const configured = ((context.dataSources[0] || {}).projects || []).map( + (p) => p.key, +); +result = (data || []).filter((project) => configured.includes(project.id)); + diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json index a1c6ad38..eb973aa9 100644 --- a/plugins/Sanity/v1/defaultContent/overview.dash.json +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -20,7 +20,7 @@ "config": { "_type": "tile/data-stream", "title": "Projects", - "description": "", + "description": "Only projects with a configured API token are indexed.", "activePluginConfigIds": ["{{configId}}"], "dataStream": { "id": "{{dataStreams.[listProjects]}}", diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index eac25440..e72c14d1 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -4,10 +4,10 @@ Bring your [Sanity](https://www.sanity.io) content into SquaredUp. A single plug ## What this plugin does -You give the plugin two things: an **admin token** (used to list the projects in your organization) and a set of **per-project API tokens** (used to read each project's datasets, members and content). It then imports: +You give the plugin two things: an **admin token** (used to look up project details in your organization) and a set of **per-project API tokens** (used to read each project's datasets, members and content). It then imports: -- **Each project** your admin token can see, as a `Project` object. -- **Each dataset** in the projects you've supplied a token for, as a `Dataset` object you can scope dashboards to and drill into. +- **Each project you've supplied a token for**, as a `Project` object. Projects without a token are not indexed — they couldn't serve any data, so you can't scope dashboards to them by mistake. +- **Each dataset** in those projects, as a `Dataset` object you can scope dashboards to and drill into. - **Each member** of those projects, as a `Member` object. From there you get data streams for: @@ -24,7 +24,7 @@ Sanity uses two API surfaces: - **Management** (`api.sanity.io`) — the plugin uses your **admin token** to list projects, and the matching **per-project token** to list each project's datasets and members. - **Content** (`.api.sanity.io`) — GROQ queries and dataset stats. The plugin uses the **per-project token** matching the project being queried. -This keeps content access least-privilege: each project's token can only read that project. Datasets, members and content tiles work for any project you've added a token for; projects without a token still appear as objects, but only the project itself — no datasets, members or content. +This keeps content access least-privilege: each project's token can only read that project. Only projects you've added a token for are indexed, so every project you can scope a dashboard to can actually serve data. ## Prerequisites — getting your credentials @@ -56,11 +56,11 @@ For **each** project you want to query content in: | Object type | Represents | Example | |---|---|---| -| **Project** | A project your admin token can see | `My Studio` | +| **Project** | A project you've configured an API token for | `My Studio` | | **Dataset** | A dataset in a project you've supplied a token for | `My Studio - production` | | **Member** | A user with access to one of those projects | `Jane Doe` | -Dataset and member imports are **optional steps**: projects without a per-project token simply don't contribute datasets or members, and the rest of the import carries on. +Projects without a per-project token are skipped entirely — add a project's token to bring it (and its datasets and members) into the index on the next import. ## Out-of-the-box dashboards @@ -71,7 +71,7 @@ Dataset and member imports are **optional steps**: projects without a per-projec ## Known limitations - **No organization-level usage or billing metrics.** Sanity's HTTP API exposes no API-request counts, bandwidth, or billing figures; the **Dataset stats** stream covers per-dataset usage against plan limits. -- **Content tiles need a per-project token.** Datasets, members, GROQ query and dataset stats only work for projects you've added under **Project API tokens**. +- **Only projects with a configured token are indexed.** Projects missing from dashboards aren't an error — add the project's ID and API token under **Project API tokens** and re-import. If a configured token is invalid or expired, tiles show an error naming the affected project. - **Document type counts can be slow on large datasets.** The out-of-the-box breakdown tile groups every document by `_type` in GROQ; on very large datasets it can be slow — narrow the query to specific types if needed. ## Useful links diff --git a/plugins/Sanity/v1/ui.json b/plugins/Sanity/v1/ui.json index 412b30a2..3cff3c87 100644 --- a/plugins/Sanity/v1/ui.json +++ b/plugins/Sanity/v1/ui.json @@ -29,7 +29,7 @@ "required": true } }, - "help": "For each Sanity project you want to query, add its **Project ID** and a project API token (Viewer role). The Project ID is shown on the project page in [sanity.io/manage](https://www.sanity.io/manage); create a token under that project's API → Tokens. Datasets, members and content tiles only work for projects listed here." + "help": "For each Sanity project you want to monitor, add its **Project ID** and a project API token (Viewer role). The Project ID is shown on the project page in [sanity.io/manage](https://www.sanity.io/manage); create a token under that project's API → Tokens. Only projects listed here are indexed and shown on dashboards." }, { "type": "text", From 90c2cb9153d054364986fc7cb02847187c355bd6 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 10:02:50 +0100 Subject: [PATCH 13/26] Show every project's token status on the Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview dashboard's Projects tile was a blocks grid backed by the (now token-filtered) listProjects stream, so it only ever showed projects that were already fully configured — there was no way to see, at a glance, which of the organization's other projects still needed a token added. A new allProjects stream calls the same admin-token project listing but without the import-time token filter, and tags each row with a tokenStatus of "configured" or "missing" by checking it against the configured Project API tokens. The Overview Projects tile is now a table over this stream with a state-shaped Token Status column (a green/red health dot) alongside the project's ID, so a missing token is immediately visible and actionable — the ID is what you need to add it. listProjects keeps its existing filtered behaviour for indexing and config validation; duplicating the projects API call in a second stream was the simplest way to get an unfiltered view without weakening that filter or threading a "show all vs indexed only" flag through it. Co-Authored-By: Claude Fable 5 --- .../Sanity/v1/dataStreams/allProjects.json | 45 +++++++++++++++++++ .../v1/dataStreams/scripts/allProjects.js | 10 +++++ .../v1/defaultContent/overview.dash.json | 25 +++++------ plugins/Sanity/v1/docs/README.md | 2 +- 4 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 plugins/Sanity/v1/dataStreams/allProjects.json create mode 100644 plugins/Sanity/v1/dataStreams/scripts/allProjects.js diff --git a/plugins/Sanity/v1/dataStreams/allProjects.json b/plugins/Sanity/v1/dataStreams/allProjects.json new file mode 100644 index 00000000..ee881ee8 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/allProjects.json @@ -0,0 +1,45 @@ +{ + "name": "allProjects", + "displayName": "All Projects", + "description": "Every project the admin token can see, flagging which have a content API token configured", + "tags": ["Project"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "expandInnerObjects": false, + "endpointPath": "projects", + "getArgs": [], + "headers": [], + "postRequestScript": "allProjects.js" + }, + "metadata": [ + { + "name": "displayName", + "shape": "string", + "displayName": "Name", + "role": "label" + }, + { + "name": "tokenStatus", + "displayName": "Token Status", + "shape": [ + "state", + { + "map": { + "success": ["configured"], + "error": ["missing"] + } + } + ] + }, + { + "name": "id", + "shape": "string", + "displayName": "Project ID" + } + ], + "timeframes": false +} diff --git a/plugins/Sanity/v1/dataStreams/scripts/allProjects.js b/plugins/Sanity/v1/dataStreams/scripts/allProjects.js new file mode 100644 index 00000000..3ce1e2ea --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/allProjects.js @@ -0,0 +1,10 @@ +// data is Sanity's raw project list. Flag whether each project has a +// matching entry in the configured Project API tokens, so the dashboard +// can show which projects still need a token added. +const configured = new Set( + ((context.dataSources[0] || {}).projects || []).map((p) => p.key), +); +result = (data || []).map((project) => ({ + ...project, + tokenStatus: configured.has(project.id) ? "configured" : "missing", +})); diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json index eb973aa9..6d1da330 100644 --- a/plugins/Sanity/v1/defaultContent/overview.dash.json +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -179,32 +179,27 @@ "x": 0, "y": 2, "w": 4, - "h": 4, + "h": 2, "moved": false, "static": false, "z": 0, "config": { "_type": "tile/data-stream", "title": "Projects", - "description": "", + "description": "Every project in the organization. Add a project's API token under Project API tokens to bring its datasets and members into SquaredUp.", "activePluginConfigIds": ["{{configId}}"], "dataStream": { - "id": "datastream-properties", + "id": "{{dataStreams.[allProjects]}}", + "name": "allProjects", "pluginConfigId": "{{configId}}" }, - "scope": { - "scope": "{{scopes.[Projects]}}", - "workspace": "{{workspaceId}}" - }, "timeframe": "none", "visualisation": { - "type": "data-stream-blocks", + "type": "data-stream-table", "config": { - "data-stream-blocks": { - "labelColumn": "name", - "stateColumn": "none", - "linkColumn": "name", - "columns": 4 + "data-stream-table": { + "transpose": false, + "columnOrder": ["displayName", "tokenStatus", "id"] } } } @@ -213,7 +208,7 @@ { "i": "b559b8ba-3a0a-4307-8b1b-b0351902a729", "x": 0, - "y": 6, + "y": 4, "w": 2, "h": 4, "moved": false, @@ -266,7 +261,7 @@ { "i": "147db02e-9f70-4db9-bff2-fd8264cd5921", "x": 2, - "y": 6, + "y": 4, "w": 2, "h": 4, "moved": false, diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index e72c14d1..bd253add 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -64,7 +64,7 @@ Projects without a per-project token are skipped entirely — add a project's to ## Out-of-the-box dashboards -- **Overview** — organization-wide summary: project, dataset and document counts, total JSON stored, all projects at a glance, documents by dataset, and how close each dataset is to its plan limits. +- **Overview** — organization-wide summary: project, dataset and document counts, total JSON stored, every project in the organization with whether its API token is configured, documents by dataset, and how close each dataset is to its plan limits. - **Projects** — a per-project drilldown (pick the project via the dashboard variable): project details, its datasets and its members. - **Dataset** — a per-dataset drilldown (pick the dataset via the dashboard variable): details, document type breakdown, usage gauges showing how close the dataset is to its plan limits, and a customisable GROQ query tile. From 860eb182fe3298e0d59700ce73e993fcca140640 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 10:04:34 +0100 Subject: [PATCH 14/26] Keep description to one sentence --- plugins/Sanity/v1/dataStreams/datasets.json | 2 +- plugins/Sanity/v1/dataStreams/listProjects.json | 2 +- plugins/Sanity/v1/defaultContent/projects.dash.json | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index 96f0f9d3..5a9f0c10 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -1,7 +1,7 @@ { "name": "datasets", "displayName": "Datasets", - "description": "Lists the datasets in a project, with their access-control mode.", + "description": "Lists the datasets in a project, with their access-control mode", "tags": ["Datasets"], "baseDataSourceName": "httpRequestScopedSingle", "matches": { diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json index 971a37cc..faecc309 100644 --- a/plugins/Sanity/v1/dataStreams/listProjects.json +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -1,7 +1,7 @@ { "name": "listProjects", "displayName": "Projects", - "description": "Lists the projects that have a content API token configured. Backs config validation and the project import.", + "description": "Lists the projects that have a content API token configured", "tags": ["Project"], "baseDataSourceName": "httpRequestUnscoped", "config": { diff --git a/plugins/Sanity/v1/defaultContent/projects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json index bb9ea6cf..215b64cf 100644 --- a/plugins/Sanity/v1/defaultContent/projects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -73,8 +73,7 @@ "type": "data-stream-table", "config": { "data-stream-table": { - "transpose": false, - "columnOrder": ["name", "aclMode"] + "transpose": false } } } @@ -110,8 +109,7 @@ "type": "data-stream-table", "config": { "data-stream-table": { - "transpose": false, - "columnOrder": ["name", "email", "roles"] + "transpose": false } } } From 8cabb6e6e52f36959bca3952802cb792704753bc Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 11:16:03 +0100 Subject: [PATCH 15/26] update readme --- plugins/Sanity/v1/docs/README.md | 54 +++++++++++--------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index bd253add..39d37e9d 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -1,48 +1,26 @@ # Sanity -Bring your [Sanity](https://www.sanity.io) content into SquaredUp. A single plugin instance can cover **many Sanity projects** at once: it indexes the projects your admin token can see — plus each project's datasets and members — and lets you run GROQ queries, break content down by document type, and watch dataset usage against plan limits. +Bring your [Sanity](https://www.sanity.io) content into SquaredUp: index projects, datasets and members, run GROQ queries, and watch dataset usage against plan limits. -## What this plugin does +## Setup -You give the plugin two things: an **admin token** (used to look up project details in your organization) and a set of **per-project API tokens** (used to read each project's datasets, members and content). It then imports: - -- **Each project you've supplied a token for**, as a `Project` object. Projects without a token are not indexed — they couldn't serve any data, so you can't scope dashboards to them by mistake. -- **Each dataset** in those projects, as a `Dataset` object you can scope dashboards to and drill into. -- **Each member** of those projects, as a `Member` object. - -From there you get data streams for: - -- **Custom GROQ query** — run any GROQ query against a dataset straight from a tile (the out-of-the-box dashboard uses this to break a dataset down by document type). -- **Dataset stats** — a dataset's usage figures and how close it is to plan limits. -- **Datasets** — a table of a project's datasets and their access modes. -- **Members** — the people with access to a project and their roles. - -## Why two kinds of token? - -Sanity uses two API surfaces: - -- **Management** (`api.sanity.io`) — the plugin uses your **admin token** to list projects, and the matching **per-project token** to list each project's datasets and members. -- **Content** (`.api.sanity.io`) — GROQ queries and dataset stats. The plugin uses the **per-project token** matching the project being queried. - -This keeps content access least-privilege: each project's token can only read that project. Only projects you've added a token for are indexed, so every project you can scope a dashboard to can actually serve data. - -## Prerequisites — getting your credentials +Sanity uses two separate credentials: an **admin token**, used only to look up project display names and creation dates, and a **per-project token** for each project you want to monitor, used to read that project's datasets, members and content. Only projects you add a per-project token for are imported — this keeps content access least-privilege, since each project's token can only read that project. ### Admin API token 1. Sign in to [sanity.io/manage](https://www.sanity.io/manage). 2. Choose an organization-level or project token with enough access to list the projects you care about. A **personal token** (from an admin account) or an organization token works well. 3. If creating a project token: open a project → **API → Tokens → Add API token**, and copy the value (shown once). +4. Paste this token into the **Admin API token** field. -### Project IDs and per-project tokens +### Project API tokens -For **each** project you want to query content in: +For **each** project you want to monitor: 1. In [sanity.io/manage](https://www.sanity.io/manage), open the project. 2. Note the **Project ID** — an 8-character string shown on the project page and in its URL (`…/manage/project/`). 3. Go to **API → Tokens → Add API token**, choose the **Viewer** role (read-only), and copy the token value. - -> **Private datasets** require a token; **public** datasets can be queried without one, but a token is still recommended. +4. Paste the **Project ID** and the token as a pair into the **Project API tokens** field — add one pair per project you want to monitor. ## Configuration fields @@ -52,6 +30,16 @@ For **each** project you want to query content in: | **Project API tokens** | A list of **Project ID → API token** pairs, one per project you want to read datasets, members and content from | Project ID from the project page; Viewer token from API → Tokens | Yes | | **Content API version** | The dated Sanity API version used for GROQ queries (advanced) | Defaults to `v2025-02-19`; leave as-is unless you need a specific version | No | +## What is monitored + +A single plugin instance can cover **many Sanity projects** at once: it indexes each project you've supplied a token for, plus that project's datasets and members. + +- **Projects** — every project you've configured a token for. Imported as objects. +- **Datasets** — a project's datasets and their access modes, imported as objects you can scope dashboards to and drill into. +- **Members** — the people with access to a project and their roles. +- **Custom GROQ query** — run any GROQ query against a dataset straight from a tile (the out-of-the-box dashboard uses this to break a dataset down by document type). +- **Dataset stats** — a dataset's usage figures and how close it is to plan limits. + ## What gets indexed | Object type | Represents | Example | @@ -60,7 +48,7 @@ For **each** project you want to query content in: | **Dataset** | A dataset in a project you've supplied a token for | `My Studio - production` | | **Member** | A user with access to one of those projects | `Jane Doe` | -Projects without a per-project token are skipped entirely — add a project's token to bring it (and its datasets and members) into the index on the next import. +Projects without a per-project token are skipped entirely — add a project's token and re-import to bring it (and its datasets and members) into the index. The Overview dashboard's Projects table lists every project in the organization and flags which ones are still missing a token. ## Out-of-the-box dashboards @@ -73,9 +61,3 @@ Projects without a per-project token are skipped entirely — add a project's to - **No organization-level usage or billing metrics.** Sanity's HTTP API exposes no API-request counts, bandwidth, or billing figures; the **Dataset stats** stream covers per-dataset usage against plan limits. - **Only projects with a configured token are indexed.** Projects missing from dashboards aren't an error — add the project's ID and API token under **Project API tokens** and re-import. If a configured token is invalid or expired, tiles show an error naming the affected project. - **Document type counts can be slow on large datasets.** The out-of-the-box breakdown tile groups every document by `_type` in GROQ; on very large datasets it can be slow — narrow the query to specific types if needed. - -## Useful links - -- [Sanity HTTP API reference](https://www.sanity.io/docs/http-reference) -- [GROQ query language](https://www.sanity.io/docs/groq) -- [Manage your Sanity projects](https://www.sanity.io/manage) From 86a8c8452dfebf594f4ae9a6568c0034a65c0f22 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:45:08 +0100 Subject: [PATCH 16/26] Escape project names in token error messages The no-token error script interpolates {{object.name}} directly into a single-quoted JavaScript string, so a project name containing an apostrophe would break the generated script. JSON-encode the name at template time instead, in both the datasets and members streams. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/datasets.json | 2 +- plugins/Sanity/v1/dataStreams/members.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index 5a9f0c10..f2d9bd5b 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -14,7 +14,7 @@ "httpMethod": "get", "errorHandling": { "type": "script", - "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.name}}. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its datasets.';" + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its datasets.';" }, "paging": { "mode": "none" diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index 4ff67594..0e9072af 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -15,7 +15,7 @@ "httpMethod": "get", "errorHandling": { "type": "script", - "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project {{object.name}}. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its members.';" + "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its members.';" }, "endpointPath": "access/project/{{object.rawId}}/users", "getArgs": [], From 951cd196ecda8a716479c6cef2b409f33cb4bdac Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:45:53 +0100 Subject: [PATCH 17/26] Derive dataset uid and displayName in the script Both fields were computed metadata using mustache-style value expressions, but the coding guidelines prefer row transformations in the post-request script over value expressions for performance. The script already derives datasetId, projectName and projectId, so uid and displayName move alongside them and become plain metadata fields. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/datasets.json | 4 ---- plugins/Sanity/v1/dataStreams/scripts/datasets.js | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index f2d9bd5b..10d7ee98 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -67,16 +67,12 @@ "name": "uid", "displayName": "uid", "shape": "string", - "computed": true, - "valueExpression": "{{ $['projectId'] + '-' + $['datasetId'] }}", "visible": false }, { "name": "displayName", "displayName": "Display Name", "shape": "string", - "computed": true, - "valueExpression": "{{ $['projectName'] + ' - ' + $['datasetId'] }}", "visible": false } ], diff --git a/plugins/Sanity/v1/dataStreams/scripts/datasets.js b/plugins/Sanity/v1/dataStreams/scripts/datasets.js index 17769ec5..eb1f4920 100644 --- a/plugins/Sanity/v1/dataStreams/scripts/datasets.js +++ b/plugins/Sanity/v1/dataStreams/scripts/datasets.js @@ -5,5 +5,6 @@ result = (data || []).map((d) => ({ datasetId: d.name, projectName: object?.name, projectId: object?.rawId, + uid: `${object?.rawId}-${d.name}`, + displayName: `${object?.name} - ${d.name}`, })); - From 6fccd099b2cb9517472c97c6e21d2d52aa672da8 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:46:17 +0100 Subject: [PATCH 18/26] Parse project createdAt as a date Sanity returns createdAt as an ISO date-time, but the string shape prevented date inference. The datasets stream already declares its createdAt as a date; this makes the projects stream consistent. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/listProjects.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json index faecc309..4d1cc415 100644 --- a/plugins/Sanity/v1/dataStreams/listProjects.json +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -38,7 +38,7 @@ }, { "name": "createdAt", - "shape": "string", + "shape": "date", "displayName": "Created" }, { From ce72435430e3b309e482691772cabc1f93955f6c Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:46:41 +0100 Subject: [PATCH 19/26] Describe the document type counts query The GROQ query behind the Document Type Counts tile samples one representative document per _type and counts matches, which is not obvious at a glance, and the tile description was empty. Explain the approach so dashboard maintainers don't have to re-derive it. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/defaultContent/dataset.dash.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json index c94b691b..2c358886 100644 --- a/plugins/Sanity/v1/defaultContent/dataset.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -55,7 +55,7 @@ "config": { "_type": "tile/data-stream", "title": "Document Type Counts", - "description": "", + "description": "Counts documents per _type using one representative document per type (top 20).", "activePluginConfigIds": ["{{configId}}"], "dataStream": { "id": "{{dataStreams.[customQuery]}}", From 54125d765118ec8837134e4f557bb19bce6b8fac Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:47:06 +0100 Subject: [PATCH 20/26] Match dashboard timeframes to their tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three dashboards declared a last24hours timeframe while every tile pins its own timeframe to none, so the dashboard time selector had no effect anywhere — an unadjusted boilerplate default. Setting the dashboard level to none reflects what the tiles actually use. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/defaultContent/dataset.dash.json | 2 +- plugins/Sanity/v1/defaultContent/overview.dash.json | 2 +- plugins/Sanity/v1/defaultContent/projects.dash.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json index 2c358886..7e52989e 100644 --- a/plugins/Sanity/v1/defaultContent/dataset.dash.json +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -1,7 +1,7 @@ { "name": "Dataset", "schemaVersion": "1.5", - "timeframe": "last24hours", + "timeframe": "none", "variables": ["{{variables.[Dataset]}}"], "dashboard": { "_type": "layout/grid", diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json index 6d1da330..efcb0fc1 100644 --- a/plugins/Sanity/v1/defaultContent/overview.dash.json +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -1,7 +1,7 @@ { "name": "Overview", "schemaVersion": "1.5", - "timeframe": "last24hours", + "timeframe": "none", "variables": [], "dashboard": { "_type": "layout/grid", diff --git a/plugins/Sanity/v1/defaultContent/projects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json index 215b64cf..ebec32b6 100644 --- a/plugins/Sanity/v1/defaultContent/projects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -1,7 +1,7 @@ { "name": "Projects", "schemaVersion": "1.5", - "timeframe": "last24hours", + "timeframe": "none", "variables": ["{{variables.[Project]}}"], "dashboard": { "_type": "layout/grid", From e7ab5312a1ad18029bfed7d37c67a44b198429e4 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:47:37 +0100 Subject: [PATCH 21/26] Give overview project tiles distinct titles The overview dashboard had two tiles both titled "Projects": a KPI counting only the projects with a configured API token, and a table listing every project in the organization. Renaming them to "Monitored Projects" and "All Projects" makes the scope of each tile clear at a glance. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/defaultContent/overview.dash.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json index efcb0fc1..93ca1eda 100644 --- a/plugins/Sanity/v1/defaultContent/overview.dash.json +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -19,7 +19,7 @@ "z": 0, "config": { "_type": "tile/data-stream", - "title": "Projects", + "title": "Monitored Projects", "description": "Only projects with a configured API token are indexed.", "activePluginConfigIds": ["{{configId}}"], "dataStream": { @@ -185,7 +185,7 @@ "z": 0, "config": { "_type": "tile/data-stream", - "title": "Projects", + "title": "All Projects", "description": "Every project in the organization. Add a project's API token under Project API tokens to bring its datasets and members into SquaredUp.", "activePluginConfigIds": ["{{configId}}"], "dataStream": { From 9610e46aa0f193403a1838f315c335197fecacba Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:47:55 +0100 Subject: [PATCH 22/26] Give each dashboard tile a unique GUID The overview Projects table tile and the Project Details tile on the projects dashboard shared the same "i" identifier, a copy/paste artifact. Both get freshly generated GUIDs. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/defaultContent/overview.dash.json | 2 +- plugins/Sanity/v1/defaultContent/projects.dash.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Sanity/v1/defaultContent/overview.dash.json b/plugins/Sanity/v1/defaultContent/overview.dash.json index 93ca1eda..2ca43968 100644 --- a/plugins/Sanity/v1/defaultContent/overview.dash.json +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -175,7 +175,7 @@ } }, { - "i": "9e3a54e7-80c9-42df-b8fc-94decb1c5505", + "i": "15f9e81f-7215-40be-bad0-7e7e09d0516d", "x": 0, "y": 2, "w": 4, diff --git a/plugins/Sanity/v1/defaultContent/projects.dash.json b/plugins/Sanity/v1/defaultContent/projects.dash.json index ebec32b6..5e834f74 100644 --- a/plugins/Sanity/v1/defaultContent/projects.dash.json +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -9,7 +9,7 @@ "version": 1, "contents": [ { - "i": "9e3a54e7-80c9-42df-b8fc-94decb1c5505", + "i": "fb7de425-6022-4b57-a8d9-0a6618e8a82f", "x": 0, "y": 0, "w": 1, From d3b5ed54b6af850cb462d502bc94b3d207eebae0 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:48:07 +0100 Subject: [PATCH 23/26] Document correct admin token creation steps The Admin API token section offered a project-scoped token as an option and then gave project-token creation steps, duplicating the Project API tokens section. A project token cannot list all the projects in an organization, so following those steps would break project listing. The steps now match the ui.json help text: use an organization token or a personal token from an admin account. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Sanity/v1/docs/README.md b/plugins/Sanity/v1/docs/README.md index 39d37e9d..003a4c82 100644 --- a/plugins/Sanity/v1/docs/README.md +++ b/plugins/Sanity/v1/docs/README.md @@ -9,8 +9,8 @@ Sanity uses two separate credentials: an **admin token**, used only to look up p ### Admin API token 1. Sign in to [sanity.io/manage](https://www.sanity.io/manage). -2. Choose an organization-level or project token with enough access to list the projects you care about. A **personal token** (from an admin account) or an organization token works well. -3. If creating a project token: open a project → **API → Tokens → Add API token**, and copy the value (shown once). +2. Use an **organization token** or a **personal token from an admin account** — a project-scoped token cannot list all the projects in your organization. +3. Create the token under your organization (or account) settings → **API → Tokens → Add API token**, and copy the value (shown once). 4. Paste this token into the **Admin API token** field. ### Project API tokens From 705796a3a007769ff12f00808bcb9ae1d1cca712 Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 14:48:26 +0100 Subject: [PATCH 24/26] Index members per project membership Member objects were indexed by sanityUserId alone, so a user with access to several projects collapsed into a single object and only one project's membership data (projectId, roles) survived the merge. Objects are now keyed by a composite of the user id and the project being imported, keeping each membership distinct. The project id comes from the import scope object rather than the memberships array so the id stays stable even if a membership entry is missing. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/members.json | 6 ++++++ plugins/Sanity/v1/dataStreams/scripts/members.js | 4 ++++ plugins/Sanity/v1/indexDefinitions/default.json | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index 0e9072af..165fac4d 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -67,6 +67,12 @@ "sourceType": "Project", "visible": false }, + { + "name": "membershipId", + "displayName": "Membership ID", + "shape": "string", + "visible": false + }, { "name": "project", "displayName": "Project", diff --git a/plugins/Sanity/v1/dataStreams/scripts/members.js b/plugins/Sanity/v1/dataStreams/scripts/members.js index 97af88f9..4885b09e 100644 --- a/plugins/Sanity/v1/dataStreams/scripts/members.js +++ b/plugins/Sanity/v1/dataStreams/scripts/members.js @@ -2,10 +2,14 @@ // Flatten each member into name, email, roles columns. // `data` is the parsed response body: { data: [...], nextCursor, totalCount } // pathToData is ignored when postRequestScript is set, so navigate manually. +const object = context.objects[0]; const members = (data && data.data) || []; +// sanityUserId alone is not unique across projects: the same user imported from two +// projects would merge into one object, keeping only one project's membership data. result = members.map((user) => ({ ...user, + membershipId: `${user.sanityUserId}:${object?.rawId}`, name: user.profile && user.profile.displayName, email: user.profile && user.profile.email, roles: (user.memberships || []) diff --git a/plugins/Sanity/v1/indexDefinitions/default.json b/plugins/Sanity/v1/indexDefinitions/default.json index fb367ad2..91fad2d5 100644 --- a/plugins/Sanity/v1/indexDefinitions/default.json +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -47,7 +47,7 @@ }, "timeframe": "none", "objectMapping": { - "id": "sanityUserId", + "id": "membershipId", "name": "profile.displayName", "type": { "value": "Member" }, "properties": [ From d4e1471491bcc8c2402568116aaf214a1f3edc0f Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 15:06:28 +0100 Subject: [PATCH 25/26] Scope member roles to the imported project The Access API returns each user's memberships across resources, but the members stream flattened roleNames over the whole array and the metadata read memberships.0.resourceId for the project. A user who belongs to several projects could therefore show another project's id and a merged role list. Each row now keeps only the membership whose resourceId matches the project being imported and derives its roles from that membership alone. Co-Authored-By: Claude Fable 5 --- .../Sanity/v1/dataStreams/scripts/members.js | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plugins/Sanity/v1/dataStreams/scripts/members.js b/plugins/Sanity/v1/dataStreams/scripts/members.js index 4885b09e..7af92d83 100644 --- a/plugins/Sanity/v1/dataStreams/scripts/members.js +++ b/plugins/Sanity/v1/dataStreams/scripts/members.js @@ -7,15 +7,21 @@ const members = (data && data.data) || []; // sanityUserId alone is not unique across projects: the same user imported from two // projects would merge into one object, keeping only one project's membership data. -result = members.map((user) => ({ - ...user, - membershipId: `${user.sanityUserId}:${object?.rawId}`, - name: user.profile && user.profile.displayName, - email: user.profile && user.profile.email, - roles: (user.memberships || []) - .flatMap((m) => m.roleNames || []) - .join(", "), - sanityUserId: user.sanityUserId, - imageUrl: user.profile && user.profile.imageUrl, -})); +// A user's memberships array can span resources, so keep only the membership for +// the project being imported — otherwise projectId and roles can come from another +// project the user belongs to. +result = members.map((user) => { + const membership = (user.memberships || []).find((m) => m.resourceId === object?.rawId); + + return { + ...user, + memberships: membership ? [membership] : [], + membershipId: `${user.sanityUserId}:${object?.rawId}`, + name: user.profile && user.profile.displayName, + email: user.profile && user.profile.email, + roles: ((membership && membership.roleNames) || []).join(", "), + sanityUserId: user.sanityUserId, + imageUrl: user.profile && user.profile.imageUrl, + }; +}); From 02103b79a8a2ee1eab37a5b48fb859ca7183a1fb Mon Sep 17 00:00:00 2001 From: Andrew Harris Date: Wed, 15 Jul 2026 15:44:08 +0100 Subject: [PATCH 26/26] Load error handling scripts from script files The datasets and members error handlers were single-line inline strings in the stream JSON, which is hard to read and edit. The deploy pipeline inlines any bare .js reference under dataStreams/scripts/ back into the config (the same mechanism postRequestScript uses, and the errorHandling/ folder the platform's own export produces), so the scripts now live as formatted files in scripts/errorHandling/ and the JSON just references them. Co-Authored-By: Claude Fable 5 --- plugins/Sanity/v1/dataStreams/datasets.json | 2 +- plugins/Sanity/v1/dataStreams/members.json | 2 +- .../Sanity/v1/dataStreams/scripts/errorHandling/datasets.js | 6 ++++++ .../Sanity/v1/dataStreams/scripts/errorHandling/members.js | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 plugins/Sanity/v1/dataStreams/scripts/errorHandling/datasets.js create mode 100644 plugins/Sanity/v1/dataStreams/scripts/errorHandling/members.js diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json index 10d7ee98..c9388d5f 100644 --- a/plugins/Sanity/v1/dataStreams/datasets.json +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -14,7 +14,7 @@ "httpMethod": "get", "errorHandling": { "type": "script", - "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its datasets.';" + "script": "errorHandling/datasets.js" }, "paging": { "mode": "none" diff --git a/plugins/Sanity/v1/dataStreams/members.json b/plugins/Sanity/v1/dataStreams/members.json index 165fac4d..664d67c4 100644 --- a/plugins/Sanity/v1/dataStreams/members.json +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -15,7 +15,7 @@ "httpMethod": "get", "errorHandling": { "type": "script", - "script": "const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); result = hasToken ? (apiMsg || ('Request failed with HTTP ' + response.status)) : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its members.';" + "script": "errorHandling/members.js" }, "endpointPath": "access/project/{{object.rawId}}/users", "getArgs": [], diff --git a/plugins/Sanity/v1/dataStreams/scripts/errorHandling/datasets.js b/plugins/Sanity/v1/dataStreams/scripts/errorHandling/datasets.js new file mode 100644 index 00000000..2d0ef53f --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/errorHandling/datasets.js @@ -0,0 +1,6 @@ +const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; +const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); + +result = hasToken + ? (apiMsg || ('Request failed with HTTP ' + response.status)) + : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its datasets.'; diff --git a/plugins/Sanity/v1/dataStreams/scripts/errorHandling/members.js b/plugins/Sanity/v1/dataStreams/scripts/errorHandling/members.js new file mode 100644 index 00000000..bedfe971 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/errorHandling/members.js @@ -0,0 +1,6 @@ +const hasToken = {{ !!((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}; +const apiMsg = data && (data.message || (typeof data.error === 'string' ? data.error : (data.error || {}).description)); + +result = hasToken + ? (apiMsg || ('Request failed with HTTP ' + response.status)) + : 'No API token is configured for project ' + {{ JSON.stringify(object.name) }} + '. Add its Project ID and API token under Project API tokens in the Sanity data source settings to see its members.';