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..3511d0ad --- /dev/null +++ b/plugins/Sanity/v1/custom_types.json @@ -0,0 +1,16 @@ +[ + { + "name": "Project", + "sourceType": "Project", + "icon": "layer-group", + "singular": "Project", + "plural": "Projects" + }, + { + "name": "Dataset", + "sourceType": "Dataset", + "icon": "layer-group", + "singular": "Dataset", + "plural": "Datasets" + } +] 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/customQuery.json b/plugins/Sanity/v1/dataStreams/customQuery.json new file mode 100644 index 00000000..9a56603b --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -0,0 +1,69 @@ +{ + "name": "customQuery", + "displayName": "Custom GROQ Query", + "description": "Run an arbitrary GROQ query against a dataset", + "tags": ["Documents"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Dataset" + } + }, + "providesPluginDiagnostics": true, + "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" + }, + "expandInnerObjects": true, + "endpointPath": "data/query/{{object.datasetId}}", + "pathToData": "result", + "getArgs": [ + { + "key": "query", + "value": "{{query}}" + }, + { + "key": "perspective", + "value": "{{perspective}}" + } + ], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" + } + ] + }, + "ui": [ + { + "type": "code", + "name": "query", + "language": "groq", + "label": "GROQ query", + "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" + }, + { + "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/datasetStats.json b/plugins/Sanity/v1/dataStreams/datasetStats.json new file mode 100644 index 00000000..73fc73c2 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -0,0 +1,97 @@ +{ + "name": "datasetStats", + "displayName": "Dataset Stats", + "description": "Get stats on how close to usage limits this dataset is", + "tags": ["Analytics"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Dataset" + } + }, + "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" + }, + "endpointPath": "data/stats/{{object.datasetId}}", + "getArgs": [], + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.projectId) || {}).value }}" + } + ], + "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": ".*" + } + ], + "timeframes": false +} diff --git a/plugins/Sanity/v1/dataStreams/datasets.json b/plugins/Sanity/v1/dataStreams/datasets.json new file mode 100644 index 00000000..c9388d5f --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/datasets.json @@ -0,0 +1,80 @@ +{ + "name": "datasets", + "displayName": "Datasets", + "description": "Lists the datasets in a project, with their access-control mode", + "tags": ["Datasets"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Project" + } + }, + "config": { + "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "errorHandling/datasets.js" + }, + "paging": { + "mode": "none" + }, + "expandInnerObjects": true, + "endpointPath": "projects/{{object.rawId}}/datasets", + "getArgs": [], + "postRequestScript": "datasets.js", + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{ ((dataSource.projects || []).find(p => p.key === object.rawId) || {}).value }}" + } + ] + }, + "metadata": [ + { + "name": "projectId", + "displayName": "Project ID", + "shape": "string", + "visible": false + }, + { + "name": "datasetId", + "shape": "string", + "displayName": "Dataset", + "role": "label" + }, + { + "name": "aclMode", + "shape": "string", + "displayName": "Access mode" + }, + { + "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", + "visible": false + }, + { + "name": "displayName", + "displayName": "Display Name", + "shape": "string", + "visible": false + } + ], + "timeframes": false +} diff --git a/plugins/Sanity/v1/dataStreams/listProjects.json b/plugins/Sanity/v1/dataStreams/listProjects.json new file mode 100644 index 00000000..4d1cc415 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/listProjects.json @@ -0,0 +1,52 @@ +{ + "name": "listProjects", + "displayName": "Projects", + "description": "Lists the projects that have a content API token configured", + "tags": ["Project"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "paging": { + "mode": "none" + }, + "expandInnerObjects": false, + "endpointPath": "projects", + "getArgs": [], + "headers": [], + "postRequestScript": "listProjects.js" + }, + "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": "date", + "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..664d67c4 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -0,0 +1,87 @@ +{ + "name": "members", + "displayName": "Members", + "description": "People with access to a project, with their roles", + "tags": ["Members"], + "baseDataSourceName": "httpRequestScopedSingle", + "matches": { + "sourceType": { + "type": "equals", + "value": "Project" + } + }, + "config": { + "baseUrl": "https://api.sanity.io/v2025-07-11/", + "httpMethod": "get", + "errorHandling": { + "type": "script", + "script": "errorHandling/members.js" + }, + "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" + }, + { + "name": "memberships.0.resourceId", + "displayName": "projectId", + "shape": "string", + "sourceType": "Project", + "visible": false + }, + { + "name": "membershipId", + "displayName": "Membership ID", + "shape": "string", + "visible": false + }, + { + "name": "project", + "displayName": "Project", + "sourceId": "memberships.0.resourceId", + "objectPropertyPath": "name" + }, + { + "pattern": ".*" + } + ], + "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/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/dataStreams/scripts/datasets.js b/plugins/Sanity/v1/dataStreams/scripts/datasets.js new file mode 100644 index 00000000..eb1f4920 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/datasets.js @@ -0,0 +1,10 @@ +const object = context.objects[0]; + +result = (data || []).map((d) => ({ + ...d, + datasetId: d.name, + projectName: object?.name, + projectId: object?.rawId, + uid: `${object?.rawId}-${d.name}`, + displayName: `${object?.name} - ${d.name}`, +})); 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.'; 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/dataStreams/scripts/members.js b/plugins/Sanity/v1/dataStreams/scripts/members.js new file mode 100644 index 00000000..7af92d83 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/scripts/members.js @@ -0,0 +1,27 @@ +// 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 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. +// 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, + }; +}); + diff --git a/plugins/Sanity/v1/defaultContent/dataset.dash.json b/plugins/Sanity/v1/defaultContent/dataset.dash.json new file mode 100644 index 00000000..7e52989e --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/dataset.dash.json @@ -0,0 +1,304 @@ +{ + "name": "Dataset", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": ["{{variables.[Dataset]}}"], + "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": "Dataset Details", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "datastream-properties", + "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": 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": "Counts documents per _type using one representative document per type (top 20).", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[customQuery]}}", + "name": "customQuery", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "query": "*[ _id == *[_type == ^._type][0]._id ]{ \"documentType\": _type, \"count\": count(*[_type == ^._type]) } | order(count desc) [0...20]", + "perspective": "published" + } + }, + "scope": { + "scope": "{{scopes.[Datasets]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Dataset]}}" + }, + "variables": ["{{variables.[Dataset]}}"], + "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": "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, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Custom GROQ Query", + "description": "Explore the tile to customise the GROQ query and dataset.", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[customQuery]}}", + "name": "customQuery", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "query": "*[ !(_id in path(\"_.**\")) ] | order(_updatedAt desc) [0...20] {_id, _type, _updatedAt}", + "perspective": "published" + } + }, + "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 + } + } + } + } + } + ] + } +} diff --git a/plugins/Sanity/v1/defaultContent/manifest.json b/plugins/Sanity/v1/defaultContent/manifest.json new file mode 100644 index 00000000..2f5d1fc8 --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/manifest.json @@ -0,0 +1,7 @@ +{ + "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..2ca43968 --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/overview.dash.json @@ -0,0 +1,307 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "none", + "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": "Monitored Projects", + "description": "Only projects with a configured API token are indexed.", + "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": "15f9e81f-7215-40be-bad0-7e7e09d0516d", + "x": 0, + "y": 2, + "w": 4, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "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": { + "id": "{{dataStreams.[allProjects]}}", + "name": "allProjects", + "pluginConfigId": "{{configId}}" + }, + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": ["displayName", "tokenStatus", "id"] + } + } + } + } + }, + { + "i": "b559b8ba-3a0a-4307-8b1b-b0351902a729", + "x": 0, + "y": 4, + "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": 4, + "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 new file mode 100644 index 00000000..5e834f74 --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/projects.dash.json @@ -0,0 +1,120 @@ +{ + "name": "Projects", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": ["{{variables.[Project]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "fb7de425-6022-4b57-a8d9-0a6618e8a82f", + "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.[Projects]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Project]}}" + }, + "variables": ["{{variables.[Project]}}"], + "timeframe": "none", + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "4ded0fa6-3ac1-4b0a-af03-123928006951", + "x": 1, + "y": 0, + "w": 3, + "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 + } + } + } + } + }, + { + "i": "cb6c1ad8-25f4-4e50-b5c9-619f1cadd6f8", + "x": 0, + "y": 4, + "w": 4, + "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 + } + } + } + } + } + ] + } +} diff --git a/plugins/Sanity/v1/defaultContent/scopes.json b/plugins/Sanity/v1/defaultContent/scopes.json new file mode 100644 index 00000000..790f1c4e --- /dev/null +++ b/plugins/Sanity/v1/defaultContent/scopes.json @@ -0,0 +1,26 @@ +[ + { + "name": "Projects", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Project"] } + }, + "variable": { + "name": "Project", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Datasets", + "matches": { + "sourceType": { "type": "oneOf", "values": ["Dataset"] } + }, + "variable": { + "name": "Dataset", + "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..003a4c82 --- /dev/null +++ b/plugins/Sanity/v1/docs/README.md @@ -0,0 +1,63 @@ +# Sanity + +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. + +## Setup + +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. 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 + +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. +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 + +| Field | What it is | Where to find it | Required | +|---|---|---|---| +| **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 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 | +|---|---|---| +| **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` | + +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 + +- **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. + +## 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. +- **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. 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..91fad2d5 --- /dev/null +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -0,0 +1,65 @@ +{ + "steps": [ + { + "name": "projects", + "dataStream": { + "name": "listProjects" + }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "displayName", + "type": { "value": "Project" }, + "properties": ["organizationId", "studioHost", "createdAt"] + } + }, + { + "name": "datasets", + "dataStream": { + "name": "datasets" + }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Project\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "uid", + "name": "displayName", + "type": { "value": "Dataset" }, + "properties": [ + "datasetId", + "aclMode", + "createdAt", + "createdByUserId", + "projectId" + ] + }, + "optional": true, + "dependsOn": ["projects"] + }, + { + "name": "members", + "dataStream": { + "name": "members" + }, + "scope": { + "query": "g.V().has(\"sourceType\", \"Project\")" + }, + "timeframe": "none", + "objectMapping": { + "id": "membershipId", + "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 new file mode 100644 index 00000000..968271d0 --- /dev/null +++ b/plugins/Sanity/v1/metadata.json @@ -0,0 +1,44 @@ +{ + "name": "sanity", + "displayName": "Sanity", + "version": "1.0.0", + "author": { + "name": "@andrewmumblebee", + "type": "community" + }, + "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", "Member"], + "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..3cff3c87 --- /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 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", + "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 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", + "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. Leave as the default unless you need a specific version." + } +]