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..d0818acd --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/customQuery.json @@ -0,0 +1,65 @@ +{ + "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", + "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..c97a80be --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/datasetStats.json @@ -0,0 +1,93 @@ +{ + "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", + "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..ed80a808 --- /dev/null +++ b/plugins/Sanity/v1/dataStreams/members.json @@ -0,0 +1,83 @@ +{ + "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", + "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/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..f79aceb5 --- /dev/null +++ b/plugins/Sanity/v1/docs/README.md @@ -0,0 +1,64 @@ +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, via the [Sanity HTTP API](https://www.sanity.io/docs/http-api). + +## 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 this plugin monitors + +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. + +- **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. + +The out-of-the-box dashboards include an organization-wide **Overview** plus a **Projects** and **Dataset** perspective. + +## Data streams + +- **All Projects** — every project in the organization, flagging whether it has an API token configured; account-wide. +- **Datasets** — a project's datasets and their access-control mode; per project. +- **Members** — the people with access to a project and their roles; per project. +- **Dataset Stats** — a dataset's usage figures and how close it is to plan limits; per dataset. +- **Custom GROQ Query** — runs an arbitrary GROQ query against a dataset; per dataset. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ------------------------------------------------- | ---------------------------------------------------- | +| **Project** | `GET /v2021-06-07/projects` | A project you've configured an API token for. | +| **Dataset** | `GET /v2021-06-07/projects/{projectId}/datasets` | A dataset in a project you've supplied a token for. | + +**Relationships:** each Dataset links to its parent Project. + +## 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 a token are skipped entirely, along with their datasets — this isn't an error; add the project's ID and API token under **Project API tokens** and re-import to bring it in. 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..268170ed --- /dev/null +++ b/plugins/Sanity/v1/indexDefinitions/default.json @@ -0,0 +1,41 @@ +{ + "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"] + } + ] +} 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." + } +]