diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4c0b7722..1558ccea 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,5 +34,6 @@ plugins/WorldCup2026/* @TimWheeler-SQUP plugins/Notion/* @clarkd plugins/Algolia/* @andrewmumblebee plugins/Vercel/* @andrewmumblebee +plugins/Healthchecks.io/ @adamkinniburgh diff --git a/plugins/Healthchecks.io/v1/configValidation.json b/plugins/Healthchecks.io/v1/configValidation.json new file mode 100644 index 00000000..ce94eb1c --- /dev/null +++ b/plugins/Healthchecks.io/v1/configValidation.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { + "name": "checks" + }, + "required": true, + "error": "Could not connect. Check the API key is a project API key (not a ping key) and the server URL is correct.", + "success": "Connected successfully." + } + ] +} diff --git a/plugins/Healthchecks.io/v1/correlationRules/relate-check-to-tag.json b/plugins/Healthchecks.io/v1/correlationRules/relate-check-to-tag.json new file mode 100644 index 00000000..8b2dc4d5 --- /dev/null +++ b/plugins/Healthchecks.io/v1/correlationRules/relate-check-to-tag.json @@ -0,0 +1,20 @@ +{ + "displayName": "Check has Tag", + "source": { + "types": "Check" + }, + "target": { + "types": "Tag" + }, + "conditions": [ + { + "sourceProperty": "checkTags", + "operator": "equals", + "targetProperty": "name" + } + ], + "labels": { + "forward": "has tag", + "reverse": "tag of" + } +} diff --git a/plugins/Healthchecks.io/v1/cspell.json b/plugins/Healthchecks.io/v1/cspell.json new file mode 100644 index 00000000..d3d5d45a --- /dev/null +++ b/plugins/Healthchecks.io/v1/cspell.json @@ -0,0 +1,3 @@ +{ + "words": ["healthchecks", "OnCalendar", "systemd"] +} diff --git a/plugins/Healthchecks.io/v1/custom_types.json b/plugins/Healthchecks.io/v1/custom_types.json new file mode 100644 index 00000000..f011091d --- /dev/null +++ b/plugins/Healthchecks.io/v1/custom_types.json @@ -0,0 +1,16 @@ +[ + { + "name": "Check", + "sourceType": "Check", + "icon": "heart-pulse", + "singular": "Check", + "plural": "Checks" + }, + { + "name": "Tag", + "sourceType": "Tag", + "icon": "tag", + "singular": "Tag", + "plural": "Tags" + } +] diff --git a/plugins/Healthchecks.io/v1/dataStreams/checks.json b/plugins/Healthchecks.io/v1/dataStreams/checks.json new file mode 100644 index 00000000..d708219e --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/checks.json @@ -0,0 +1,188 @@ +{ + "name": "checks", + "displayName": "Checks", + "description": "Current status, schedule and last ping of each check in the project", + "tags": ["Monitoring", "Status"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "checks/", + "getArgs": [ + { + "key": "tag", + "value": "{{ (typeof tag !== 'undefined' && tag && tag.length) ? tag.flatMap((o) => o.rawId) : null }}" + } + ], + "paging": { + "mode": "none" + }, + "postRequestScript": "checks.js" + }, + "matches": "none", + "ui": [ + { + "type": "objects", + "name": "checks", + "label": "Filter by check", + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Check"] + } + } + }, + { + "type": "objects", + "name": "tag", + "label": "Filter by tag", + "help": "Returns only checks that have every selected tag.", + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Tag"] + } + } + } + ], + "metadata": [ + { + "name": "id", + "displayName": "Check ID", + "shape": "string", + "role": "id", + "visible": false + }, + { + "name": "name", + "displayName": "Name", + "shape": "string", + "role": "label" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": ["up"], + "warning": ["grace"], + "error": ["down"], + "unmonitored": ["paused"], + "unknown": ["new"] + } + } + ] + }, + { + "name": "started", + "displayName": "Running", + "shape": "boolean" + }, + { + "name": "last_ping", + "displayName": "Last ping", + "shape": "date", + "role": "timestamp" + }, + { + "name": "next_ping", + "displayName": "Next ping due", + "shape": "date" + }, + { + "name": "secondsSinceLastPing", + "displayName": "Time since last ping", + "computed": true, + "valueExpression": "{{ $['last_ping'] ? Math.max(0, Math.round((Date.now() - Date.parse($['last_ping'])) / 1000)) : null }}", + "shape": "seconds" + }, + { + "name": "kind", + "displayName": "Kind", + "shape": "string" + }, + { + "name": "timeout", + "displayName": "Period", + "shape": "seconds" + }, + { + "name": "schedule", + "displayName": "Schedule", + "shape": "string" + }, + { + "name": "tz", + "displayName": "Time zone", + "shape": "string" + }, + { + "name": "grace", + "displayName": "Grace time", + "shape": "seconds" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": ["array", { "itemShape": "string" }] + }, + { + "name": "slug", + "displayName": "Slug", + "shape": "string" + }, + { + "name": "desc", + "displayName": "Description", + "shape": "string", + "role": "description" + }, + { + "name": "n_pings", + "displayName": "Total pings", + "shape": "number" + }, + { + "name": "manual_resume", + "displayName": "Manual resume", + "shape": "boolean", + "visible": false + }, + { + "name": "ping_url", + "displayName": "Ping URL", + "shape": "url", + "visible": false + }, + { + "name": "update_url", + "displayName": "API URL", + "visible": false + }, + { + "name": "uuid", + "displayName": "UUID", + "shape": "string", + "visible": false + }, + { + "name": "badge_url", + "displayName": "Badge URL", + "visible": false + }, + { + "name": "checkUrl", + "displayName": "Link", + "computed": true, + "valueExpression": "{{ $['update_url'] ? $['update_url'].replace(/[/]api[/]v[0-9]+[/]checks[/]/, '/checks/') + '/details/' : ($['badge_url'] ? $['badge_url'].replace(/[/]b[/].*$/, '') + '/cloaked/' + $['id'] + '/' : null) }}", + "shape": ["url", { "label": "Open in Healthchecks.io" }] + }, + { + "sourceId": "id", + "sourceType": "Check", + "name": "name" + } + ], + "timeframes": false +} diff --git a/plugins/Healthchecks.io/v1/dataStreams/pings.json b/plugins/Healthchecks.io/v1/dataStreams/pings.json new file mode 100644 index 00000000..1d5edab0 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/pings.json @@ -0,0 +1,133 @@ +{ + "name": "pings", + "displayName": "Pings", + "description": "Pings received by a check, including job run times (requires a read-write API key)", + "tags": ["Monitoring", "Events"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "checks/{{object.uuid || object.rawId}}/pings/", + "paging": { + "mode": "none" + }, + "postRequestScript": "pings.js", + "errorHandling": { + "type": "script", + "script": "errorHandling/pings.js" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Check"] + } + }, + "metadata": [ + { + "name": "checkId", + "displayName": "Check ID", + "shape": "string", + "visible": false + }, + { + "name": "checkName", + "displayName": "Check", + "shape": "string", + "role": "label" + }, + { + "name": "checkTags", + "displayName": "Tags", + "sourceId": "checkId", + "sourceType": "Check", + "objectPropertyPath": "checkTags", + "shape": ["array", { "itemShape": "string" }] + }, + { + "name": "date", + "displayName": "Date", + "shape": "date", + "role": "timestamp" + }, + { + "name": "type", + "displayName": "Type", + "shape": "string" + }, + { + "name": "result", + "displayName": "Result", + "computed": true, + "valueExpression": "{{ $['type'] }}", + "shape": [ + "state", + { + "map": { + "success": ["success"], + "error": ["fail"], + "unknown": ["start", "log", "ign"] + } + } + ] + }, + { + "name": "duration", + "displayName": "Duration", + "shape": "seconds" + }, + { + "name": "exitstatus", + "displayName": "Exit status", + "shape": "number" + }, + { + "name": "method", + "displayName": "Method", + "shape": "string" + }, + { + "name": "scheme", + "displayName": "Scheme", + "shape": "string" + }, + { + "name": "remote_addr", + "displayName": "Source IP", + "shape": "string" + }, + { + "name": "ua", + "displayName": "User agent", + "shape": "string" + }, + { + "name": "rid", + "displayName": "Run ID", + "shape": "string", + "visible": false + }, + { + "name": "n", + "displayName": "Ping number", + "shape": "number", + "visible": false + }, + { + "sourceId": "checkId", + "sourceType": "Check", + "name": "checkName" + } + ], + "timeframes": [ + "last1hour", + "last12hours", + "last24hours", + "last7days", + "last30days" + ], + "defaultShaping": { + "sort": { + "by": [["date", "desc"]] + } + } +} diff --git a/plugins/Healthchecks.io/v1/dataStreams/scripts/checks.js b/plugins/Healthchecks.io/v1/dataStreams/scripts/checks.js new file mode 100644 index 00000000..dc7f8c54 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/scripts/checks.js @@ -0,0 +1,48 @@ +// Read-only keys return a check's `unique_key`; read-write keys return its +// `uuid` instead. Healthchecks.io derives `unique_key` from the UUID (SHA-1 of +// its first 16 hex digits), so computing it here gives every check the same +// id whichever key type the data source uses. +const sha1Hex = async (text) => { + const digest = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +}; +const uniqueKeyOf = (c) => + c.unique_key || sha1Hex(c.uuid.replace(/-/g, "").slice(0, 16)); + +// Optional `checks` object picker (or a bound dashboard variable). Empty or +// absent means every check in the project. +const unwrap = (v) => (Array.isArray(v) ? v[0] : v); +const selected = (context.config && context.config.checks) || []; +const selectedIds = new Set( + selected.map((o) => unwrap(o.rawId)).filter(Boolean), +); + +// A schedule is either a cron expression (five space-separated fields) or a +// systemd OnCalendar expression; checks without one use a simple period. +const kindOf = (schedule) => { + if (!schedule) { + return "Simple"; + } + return schedule.trim().split(/\s+/).length === 5 ? "Cron" : "OnCalendar"; +}; + +const checks = await Promise.all( + ((data && data.checks) || []).map(async (c) => { + const id = await uniqueKeyOf(c); + return { + ...c, + id, + name: c.name || c.slug || id, + kind: kindOf(c.schedule), + // The API returns tags as one space-separated string. + tags: (c.tags || "").split(/\s+/).filter(Boolean), + }; + }), +); + +result = checks.filter((c) => !selectedIds.size || selectedIds.has(c.id)); diff --git a/plugins/Healthchecks.io/v1/dataStreams/scripts/errorHandling/pings.js b/plugins/Healthchecks.io/v1/dataStreams/scripts/errorHandling/pings.js new file mode 100644 index 00000000..b315755b --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/scripts/errorHandling/pings.js @@ -0,0 +1,18 @@ +// Healthchecks.io returns API errors as {"error": "..."}, but a URL that matches +// no route returns an HTML 404 page instead. +const apiMsg = data && typeof data === "object" && data.error; +const readOnlyMsg = + "The ping log needs a Healthchecks.io read-write API key (read-only keys cannot read pings)"; + +// Only read-write keys expose a check's UUID, and the ping log route only +// accepts UUIDs, so read-only requests 404 rather than returning 401. +const object = context && context.objects && context.objects[0]; +const uuid = object && (Array.isArray(object.uuid) ? object.uuid[0] : object.uuid); + +if (!uuid || response.status === 401 || response.status === 403) { + result = readOnlyMsg + (apiMsg ? ": " + apiMsg : ""); +} else if (response.status === 404) { + result = "Check not found in Healthchecks.io (it may have been deleted)"; +} else { + result = apiMsg || "Request failed with HTTP " + response.status; +} diff --git a/plugins/Healthchecks.io/v1/dataStreams/scripts/pings.js b/plugins/Healthchecks.io/v1/dataStreams/scripts/pings.js new file mode 100644 index 00000000..cc9f2c1e --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/scripts/pings.js @@ -0,0 +1,20 @@ +// The ping log endpoint has no time filter, so keep only the pings inside the +// tile timeframe here. It returns the newest pings first, capped by plan. +const check = context.objects[0]; +const winStart = Date.parse(context.timeframe.start); +const winEnd = Date.parse(context.timeframe.end); + +result = ((data && data.pings) || []) + .filter((p) => { + const t = Date.parse(p.date); + return !Number.isNaN(t) && t >= winStart && t <= winEnd; + }) + .map((p) => ({ + ...p, + // Only present on some pings; emitted always so the columns exist for + // checks that never send start signals or exit codes. + duration: p.duration ?? null, + exitstatus: p.exitstatus ?? null, + checkId: check.rawId, + checkName: check.name, + })); diff --git a/plugins/Healthchecks.io/v1/dataStreams/scripts/statusHistory.js b/plugins/Healthchecks.io/v1/dataStreams/scripts/statusHistory.js new file mode 100644 index 00000000..28ba18f8 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/scripts/statusHistory.js @@ -0,0 +1,56 @@ +// Turns the flip events (points in time) into up/down periods clipped to the +// timeframe window. The request deliberately has no `start`, so the last flip +// before the window is available to establish the state at the window start. +const unwrap = (v) => (Array.isArray(v) ? v[0] : v); +const check = context.objects[0]; +const checkId = check.rawId; +const checkName = check.name; +const indexedStatus = unwrap(check.status); + +const now = Date.now(); +const winStart = Date.parse(context.timeframe.start); +const rawEnd = Date.parse(context.timeframe.end); +// A period never extends beyond "now". +const winEnd = Math.min(rawEnd, now); +const reachesNow = rawEnd >= now - 60 * 1000; + +// The API wraps the list as { flips: [...] }; tolerate a bare array too. +const flipList = Array.isArray(data) ? data : (data && data.flips) || []; +const flips = flipList + .map((f) => ({ t: Date.parse(f.timestamp), up: f.up ? 1 : 0 })) + .filter((f) => !Number.isNaN(f.t)) + .sort((a, b) => a.t - b.t); + +const rows = []; +const emit = (from, to, up, isLast) => { + const s = Math.max(from, winStart); + const e = Math.min(to, winEnd); + if (e <= s) { + return; + } + rows.push({ + checkId, + checkName, + status: up ? "up" : "down", + start: new Date(s).toISOString(), + end: new Date(e).toISOString(), + durationSeconds: Math.round((e - s) / 1000), + ongoing: Boolean(isLast && reachesNow), + }); +}; + +if (flips.length === 0) { + // Stable for the whole retention period: use the indexed current state. + if (indexedStatus === "success" || indexedStatus === "warning") { + emit(winStart, winEnd, true, true); + } else if (indexedStatus === "error") { + emit(winStart, winEnd, false, true); + } +} else { + flips.forEach((f, i) => { + const isLast = i === flips.length - 1; + emit(f.t, isLast ? winEnd : flips[i + 1].t, f.up, isLast); + }); +} + +result = rows; diff --git a/plugins/Healthchecks.io/v1/dataStreams/scripts/tags.js b/plugins/Healthchecks.io/v1/dataStreams/scripts/tags.js new file mode 100644 index 00000000..c3f09545 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/scripts/tags.js @@ -0,0 +1,6 @@ +// The badges endpoint is keyed by tag name; `*` is a pseudo-tag covering the +// whole project, not a real tag. +result = Object.keys((data && data.badges) || {}) + .filter((tag) => tag !== "*") + .sort() + .map((tag) => ({ name: tag })); diff --git a/plugins/Healthchecks.io/v1/dataStreams/statusHistory.json b/plugins/Healthchecks.io/v1/dataStreams/statusHistory.json new file mode 100644 index 00000000..ffdb22d0 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/statusHistory.json @@ -0,0 +1,103 @@ +{ + "name": "statusHistory", + "displayName": "Status History", + "description": "Up and down periods of a check, derived from its status changes", + "tags": ["Monitoring", "Status"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "checks/{{object.rawId}}/flips/", + "getArgs": [ + { + "key": "end", + "value": "{{ Math.floor(timeframe.unixEnd) }}" + } + ], + "paging": { + "mode": "none" + }, + "postRequestScript": "statusHistory.js" + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Check"] + } + }, + "metadata": [ + { + "name": "checkId", + "displayName": "Check ID", + "shape": "string", + "visible": false + }, + { + "name": "checkName", + "displayName": "Check", + "shape": "string", + "role": "label" + }, + { + "name": "checkTags", + "displayName": "Tags", + "sourceId": "checkId", + "sourceType": "Check", + "objectPropertyPath": "checkTags", + "shape": ["array", { "itemShape": "string" }] + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": ["up"], + "error": ["down"] + } + } + ] + }, + { + "name": "start", + "displayName": "Start", + "shape": "date", + "role": "timestamp" + }, + { + "name": "end", + "displayName": "End", + "shape": "date" + }, + { + "name": "durationSeconds", + "displayName": "Duration", + "shape": "seconds" + }, + { + "name": "ongoing", + "displayName": "Current", + "shape": "boolean" + }, + { + "sourceId": "checkId", + "sourceType": "Check", + "name": "checkName" + } + ], + "timeframes": [ + "last1hour", + "last12hours", + "last24hours", + "last7days", + "last30days", + "thisMonth", + "lastMonth", + "thisQuarter" + ], + "defaultShaping": { + "sort": { + "by": [["start", "desc"]] + } + } +} diff --git a/plugins/Healthchecks.io/v1/dataStreams/tags.json b/plugins/Healthchecks.io/v1/dataStreams/tags.json new file mode 100644 index 00000000..0a98dac1 --- /dev/null +++ b/plugins/Healthchecks.io/v1/dataStreams/tags.json @@ -0,0 +1,28 @@ +{ + "name": "tags", + "displayName": "Tags", + "description": "Tags used by checks in the project", + "tags": ["Monitoring"], + "baseDataSourceName": "httpRequestUnscoped", + "visibility": { + "type": "hidden" + }, + "config": { + "httpMethod": "get", + "endpointPath": "badges/", + "paging": { + "mode": "none" + }, + "postRequestScript": "tags.js" + }, + "matches": "none", + "metadata": [ + { + "name": "name", + "displayName": "Tag", + "shape": "string", + "role": "label" + } + ], + "timeframes": false +} diff --git a/plugins/Healthchecks.io/v1/defaultContent/check.dash.json b/plugins/Healthchecks.io/v1/defaultContent/check.dash.json new file mode 100644 index 00000000..652b1a46 --- /dev/null +++ b/plugins/Healthchecks.io/v1/defaultContent/check.dash.json @@ -0,0 +1,423 @@ +{ + "name": "Check", + "schemaVersion": "1.5", + "timeframe": "last7days", + "variables": ["{{variables.[Healthchecks Check]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "779bc7d7-5ebb-459c-9040-9b5afcdbd625", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Check Properties", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "datastream-properties", + "name": "properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Healthchecks Checks]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Healthchecks Check]}}" + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": [ + "name", + "description", + "kind", + "schedule", + "tz", + "checkTags", + "slug", + "link" + ], + "hiddenColumns": [ + "id", + "links", + "type", + "label", + "sourceId", + "sourceType", + "sourceName", + "status", + "timeout", + "grace" + ], + "columnDisplayNames": { + "tz": "Time zone", + "checkTags": "Tags" + } + } + } + } + } + }, + { + "i": "9eb7a9df-6776-4b4c-9d1c-a496e254e2cc", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Status", + "description": "Current status of this check; select it to open the check in Healthchecks.io", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "checks": { + "variable": "{{variables.[Healthchecks Check]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Checks]}}" + } + } + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-blocks", + "config": { + "data-stream-blocks": { + "labelColumn": "name", + "stateColumn": "status", + "sublabel": "status[Expanded].rawState", + "linkColumn": "checkUrl", + "columns": 1 + } + } + } + } + }, + { + "i": "b69d52d5-eb88-49fd-9716-f7050a546e37", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Time Since Last Ping", + "description": "How long ago this check last received a ping", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "checks": { + "variable": "{{variables.[Healthchecks Check]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Checks]}}" + } + } + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "secondsSinceLastPing", + "comparisonColumn": "none", + "label": "Since last ping" + } + } + } + } + }, + { + "i": "e374f82e-32dd-4ed7-960f-80311c6e0f7c", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Ping Timing", + "description": "When the next ping is due and how long the check waits before alerting", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "checks": { + "variable": "{{variables.[Healthchecks Check]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Checks]}}" + } + } + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true, + "columnOrder": [ + "next_ping", + "last_ping", + "timeout", + "grace", + "started" + ], + "hiddenColumns": [ + "name", + "status", + "status[Expanded].rawState", + "secondsSinceLastPing", + "kind", + "schedule", + "tz", + "tags", + "slug", + "desc", + "n_pings", + "checkUrl" + ] + } + } + } + } + }, + { + "i": "915c90a2-9c97-4d5b-9e6f-b200ad07434d", + "x": 1, + "y": 2, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Availability", + "description": "Share of the timeframe this check spent up and down", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[statusHistory]}}", + "name": "statusHistory", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["status", "uniqueValues"]], + "aggregate": [ + { + "type": "sum", + "names": ["durationSeconds"] + } + ] + } + }, + "scope": { + "scope": "{{scopes.[Healthchecks Checks]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Healthchecks Check]}}" + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "durationSeconds_sum", + "labelColumn": "status_uniqueValues", + "hideCenterValue": true, + "showValuesAsPercentage": true, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "46be007d-ba58-447f-a33c-9ccbb5b07e20", + "x": 2, + "y": 2, + "w": 2, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Status History", + "description": "Up and down periods of this check, newest first", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[statusHistory]}}", + "name": "statusHistory", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["start", "desc"]] + } + }, + "scope": { + "scope": "{{scopes.[Healthchecks Checks]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Healthchecks Check]}}" + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "start", + "end", + "durationSeconds", + "ongoing" + ], + "hiddenColumns": [ + "checkName", + "status[Expanded].rawState" + ] + } + } + } + } + }, + { + "i": "76113f71-3c2a-4caf-a842-7c5f290c1738", + "x": 0, + "y": 4, + "w": 4, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Ping Results", + "description": "Pings received per day, by type", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[pings]}}", + "name": "pings", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["date_byDay", "asc"]] + }, + "group": { + "by": [["date", "byDay"], ["type", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + } + }, + "scope": { + "scope": "{{scopes.[Healthchecks Checks]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Healthchecks Check]}}" + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "date_byDay", + "yAxisData": ["count"], + "xAxisGroup": "type_uniqueValues", + "xAxisLabel": "", + "yAxisLabel": "Pings", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": true, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "vertical", + "displayMode": "actual", + "showTotals": true, + "showValue": false, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "c46e4213-b49c-4499-90d4-365fc05265aa", + "x": 0, + "y": 6, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Pings", + "description": "Pings received by this check, newest first", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[pings]}}", + "name": "pings", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["date", "desc"]] + } + }, + "scope": { + "scope": "{{scopes.[Healthchecks Checks]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Healthchecks Check]}}" + }, + "variables": ["{{variables.[Healthchecks Check]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "date", + "result", + "type", + "duration", + "exitstatus", + "method", + "scheme", + "remote_addr", + "ua" + ], + "hiddenColumns": ["checkName"] + } + } + } + } + } + ] + } +} diff --git a/plugins/Healthchecks.io/v1/defaultContent/manifest.json b/plugins/Healthchecks.io/v1/defaultContent/manifest.json new file mode 100644 index 00000000..a54523d1 --- /dev/null +++ b/plugins/Healthchecks.io/v1/defaultContent/manifest.json @@ -0,0 +1,16 @@ +{ + "items": [ + { + "name": "overview", + "type": "dashboard" + }, + { + "name": "check", + "type": "dashboard" + }, + { + "name": "tag", + "type": "dashboard" + } + ] +} diff --git a/plugins/Healthchecks.io/v1/defaultContent/overview.dash.json b/plugins/Healthchecks.io/v1/defaultContent/overview.dash.json new file mode 100644 index 00000000..33f5dda0 --- /dev/null +++ b/plugins/Healthchecks.io/v1/defaultContent/overview.dash.json @@ -0,0 +1,358 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "none", + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "83bf613c-b0f7-4193-a225-8dc8c6146918", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Down", + "description": "Checks that missed their expected ping and the grace time that follows it", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "error" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "d55882c2-05f6-421f-96a5-9b0e7052f767", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Late", + "description": "Checks past their expected ping time but still within their grace time", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "warning" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "443f0c9c-7f20-4bc2-a258-98673d1cdbc4", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Up", + "description": "Checks that pinged on time", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "success" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "286aa9f6-8363-48a6-8749-1509b1db2d52", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Paused", + "description": "Checks that are paused and not being monitored", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "unmonitored" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "207fdac3-f9ad-4b44-8b01-a531df5cdd76", + "x": 0, + "y": 2, + "w": 1, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks by Status", + "description": "Current status of every check", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["status", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "adeb46cd-d28e-4d8d-ac21-0f3be515de75", + "x": 1, + "y": 2, + "w": 3, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Needing Attention", + "description": "Checks that are down or late, longest without a ping first", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "or", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "error" + }, + { + "column": "status", + "operation": "equals", + "value": "warning" + } + ] + }, + "sort": { + "by": [["secondsSinceLastPing", "desc"]] + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "status", + "secondsSinceLastPing", + "last_ping", + "next_ping", + "grace", + "tags", + "checkUrl" + ], + "hiddenColumns": [ + "status[Expanded].rawState", + "started", + "kind", + "timeout", + "schedule", + "tz", + "slug", + "desc", + "n_pings" + ] + } + } + } + } + }, + { + "i": "b16ae127-5eff-4302-bc9a-34c2c5232123", + "x": 0, + "y": 5, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "All Checks", + "description": "Every check in the project with its schedule and latest ping", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [["name", "asc"]] + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "status", + "last_ping", + "next_ping", + "kind", + "timeout", + "schedule", + "tz", + "grace", + "tags", + "checkUrl" + ], + "hiddenColumns": [ + "status[Expanded].rawState", + "started", + "secondsSinceLastPing", + "slug", + "desc", + "n_pings" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Healthchecks.io/v1/defaultContent/scopes.json b/plugins/Healthchecks.io/v1/defaultContent/scopes.json new file mode 100644 index 00000000..c426411a --- /dev/null +++ b/plugins/Healthchecks.io/v1/defaultContent/scopes.json @@ -0,0 +1,32 @@ +[ + { + "name": "Healthchecks Checks", + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Check"] + } + }, + "variable": { + "name": "Healthchecks Check", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Healthchecks Tags", + "matches": { + "sourceType": { + "type": "oneOf", + "values": ["Tag"] + } + }, + "variable": { + "name": "Healthchecks Tag", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/Healthchecks.io/v1/defaultContent/tag.dash.json b/plugins/Healthchecks.io/v1/defaultContent/tag.dash.json new file mode 100644 index 00000000..b9feef2d --- /dev/null +++ b/plugins/Healthchecks.io/v1/defaultContent/tag.dash.json @@ -0,0 +1,415 @@ +{ + "name": "Tag", + "schemaVersion": "1.5", + "timeframe": "none", + "variables": ["{{variables.[Healthchecks Tag]}}"], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "97d5fba2-a2a5-4a9e-89f7-af3197e77cde", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Down", + "description": "Checks with this tag that missed their expected ping and the grace time that follows it", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "error" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "17f483b1-6f66-4e0d-87bb-8816dc423cdc", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Late", + "description": "Checks with this tag past their expected ping time but still within their grace time", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "warning" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "38cf21b1-b5bd-4f1c-a320-d6dfd30cd7d0", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Up", + "description": "Checks with this tag that pinged on time", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "success" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "0b3011f5-b5bf-4dd8-9cbb-14a34672d1e3", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Paused", + "description": "Checks with this tag that are paused and not being monitored", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "unmonitored" + } + ] + }, + "group": { + "by": [], + "aggregate": [{ "type": "count" }] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "count", + "comparisonColumn": "none", + "label": "Checks" + } + } + } + } + }, + { + "i": "f4be1f50-a200-4696-8d91-5159449973f1", + "x": 0, + "y": 2, + "w": 1, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks by Status", + "description": "Current status of every check with this tag", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "group": { + "by": [["status", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "status_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "e802ea68-8877-46a4-bbf9-aa006de55049", + "x": 1, + "y": 2, + "w": 3, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks Needing Attention", + "description": "Checks with this tag that are down or late, longest without a ping first", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "filter": { + "multiOperation": "or", + "filters": [ + { + "column": "status", + "operation": "equals", + "value": "error" + }, + { + "column": "status", + "operation": "equals", + "value": "warning" + } + ] + }, + "sort": { + "by": [["secondsSinceLastPing", "desc"]] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "status", + "secondsSinceLastPing", + "last_ping", + "next_ping", + "grace", + "tags", + "checkUrl" + ], + "hiddenColumns": [ + "status[Expanded].rawState", + "started", + "kind", + "timeout", + "schedule", + "tz", + "slug", + "desc", + "n_pings" + ] + } + } + } + } + }, + { + "i": "5369d44e-4b07-41ad-8193-c7e633937d41", + "x": 0, + "y": 5, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Checks", + "description": "Every check with this tag, with its schedule and latest ping", + "activePluginConfigIds": ["{{configId}}"], + "timeframe": "none", + "dataStream": { + "id": "{{dataStreams.[checks]}}", + "name": "checks", + "pluginConfigId": "{{configId}}", + "dataSourceConfig": { + "tag": { + "variable": "{{variables.[Healthchecks Tag]}}", + "workspace": "{{workspaceId}}", + "scope": "{{scopes.[Healthchecks Tags]}}" + } + }, + "sort": { + "by": [["name", "asc"]] + } + }, + "variables": ["{{variables.[Healthchecks Tag]}}"], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "status", + "last_ping", + "next_ping", + "kind", + "timeout", + "schedule", + "tz", + "grace", + "tags", + "checkUrl" + ], + "hiddenColumns": [ + "status[Expanded].rawState", + "started", + "secondsSinceLastPing", + "slug", + "desc", + "n_pings" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/Healthchecks.io/v1/docs/README.md b/plugins/Healthchecks.io/v1/docs/README.md new file mode 100644 index 00000000..87d8f5b8 --- /dev/null +++ b/plugins/Healthchecks.io/v1/docs/README.md @@ -0,0 +1,59 @@ +Monitor your [Healthchecks.io](https://healthchecks.io) cron jobs, scheduled tasks and heartbeats in SquaredUp: current check status, up/down history and job run times, using the [Healthchecks.io Management API](https://healthchecks.io/docs/api/). Self-hosted [Healthchecks](https://github.com/healthchecks/healthchecks) instances are supported too. + +## Setup + +You will need a Healthchecks.io **project API key**. API keys belong to a single project, so add one data source per project you want to monitor. + +1. Sign in to [Healthchecks.io](https://healthchecks.io/accounts/login/) and open the project to monitor. +2. Go to **Settings** (the project's settings page) and find the **API Access** section. +3. Click **Create API Keys** if the project has no keys yet. Healthchecks.io creates a **read-write** key and a **read-only** key. +4. Copy the **read-only** key and paste it into the **API key** field. The plugin never changes anything in Healthchecks.io, and a read-only key covers everything except the ping log (see [Known limitations](#known-limitations)). Use the read-write key instead if you want job run times and the ping log. You can switch key type later without losing checks or dashboards. +5. Leave **Server URL** as `https://healthchecks.io`, unless you run your own Healthchecks instance. + +> ⚠️ The **Ping key** shown on the same settings page is used to *send* pings and does not work with the Management API. Use one of the **API keys**. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +| ----- | ---------- | ---------------- | -------- | +| **API key** | A project API key, sent in the `X-Api-Key` header. Read-only or read-write. | Healthchecks.io → your project → **Settings → API Access**. | Yes | +| **Server URL** | The address of the Healthchecks instance. Defaults to `https://healthchecks.io`. | Change only for a self-hosted instance, e.g. `https://healthchecks.example.com`. | Yes | +| **Ignore certificate errors** | Skips TLS certificate validation. | Enable only for a self-hosted instance with a self-signed or private CA certificate. | No | + +On save, the plugin validates the configuration by listing the project's checks. An invalid key, a ping key in place of an API key, or a wrong server URL fails setup with an authentication error. + +## What this plugin monitors + +- **Check status**: whether each check is up, late (in its grace period), down, paused or new, when it last pinged and when the next ping is due. +- **Availability**: how long each check spent up and down over a timeframe, and the outages in between. +- **Job runs**: every ping a check received, with success or failure, source IP and, for jobs that send a start signal, how long the job ran. + +The out-of-the-box dashboards include a project-wide **Overview**, a **Check** perspective for each check and a **Tag** perspective showing every check with a given tag. + +## Data streams + +- **Checks**: current status, schedule and last ping of every check in the project. **Filter by check** narrows it to selected checks; **Filter by tag** narrows it to checks that have every selected tag. +- **Status History**: up and down periods of a check over the selected timeframe, one row per period. Per check. +- **Pings**: pings received by a check over the selected timeframe, including job run time and exit status where reported. Per check, and needs a read-write API key. + +## What gets indexed + +| Object type | API source | Represents | +| ----------- | ---------- | ---------- | +| **Check** | `GET /api/v3/checks/` | A Healthchecks.io check: a cron job, scheduled task or heartbeat being monitored. | +| **Tag** | `GET /api/v3/badges/` | A tag used by one or more checks in the project. | + +Each check is indexed with its slug, kind (simple, cron or OnCalendar), period or schedule, time zone, grace time, description, tags and a link to the check in Healthchecks.io. Tags are indexed individually, so you can build scopes such as "all checks tagged `prod`". Objects are refreshed every 12 hours. The **Checks** stream always shows live status. + +**Relationships:** each Check **has tag** each Tag it carries (Tag **tag of** Check). Relationships are made within a data source, so a tag used in two projects appears as one Tag per project, each linked to its own project's checks. + +## Known limitations + +- **The ping log needs a read-write key.** Healthchecks.io read-only keys can't read pings, so **Pings** tiles show an error with a read-only key. Everything else works with either key type. +- **The ping log is capped.** Healthchecks.io returns only the newest 100 pings (free accounts) or 1,000 pings (paid accounts) per check. For frequently pinged checks, longer timeframes show only the most recent pings. +- **Job run time needs start signals.** Duration is only recorded when a job sends a `/start` ping before its success or failure ping. +- **Status history covers about three months.** Healthchecks.io keeps status changes for the current month and the two months before it, so **Status History** offers timeframes up to **This quarter**. Time before a check's earliest retained status change isn't shown. +- **Paused periods appear as down.** Healthchecks.io records status changes only as up or not up, so a period when a check was paused shows as down in **Status History**. +- **Rate limit.** Healthchecks.io allows 100 API requests per minute per project. **Status History** and **Pings** make one request per check, so keep those tiles scoped to a handful of checks rather than a whole large project. +- **One project per data source.** API keys belong to a single project. Add a data source for each project. +- **Read-only.** The plugin never creates, changes, pauses or deletes anything in Healthchecks.io. diff --git a/plugins/Healthchecks.io/v1/icon.svg b/plugins/Healthchecks.io/v1/icon.svg new file mode 100644 index 00000000..1632d509 --- /dev/null +++ b/plugins/Healthchecks.io/v1/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plugins/Healthchecks.io/v1/indexDefinitions/default.json b/plugins/Healthchecks.io/v1/indexDefinitions/default.json new file mode 100644 index 00000000..435f73f7 --- /dev/null +++ b/plugins/Healthchecks.io/v1/indexDefinitions/default.json @@ -0,0 +1,51 @@ +{ + "steps": [ + { + "name": "checks", + "dataStream": { + "name": "checks" + }, + "timeframe": "none", + "objectMapping": { + "id": "id", + "name": "name", + "type": { + "value": "Check" + }, + "properties": [ + "uuid", + "slug", + "status", + "kind", + "timeout", + "grace", + "schedule", + "tz", + { + "description": "desc" + }, + { + "checkTags": "tags" + }, + { + "link": "checkUrl" + } + ] + } + }, + { + "name": "tags", + "dataStream": { + "name": "tags" + }, + "timeframe": "none", + "objectMapping": { + "id": "name", + "name": "name", + "type": { + "value": "Tag" + } + } + } + ] +} diff --git a/plugins/Healthchecks.io/v1/metadata.json b/plugins/Healthchecks.io/v1/metadata.json new file mode 100644 index 00000000..c22b35f7 --- /dev/null +++ b/plugins/Healthchecks.io/v1/metadata.json @@ -0,0 +1,59 @@ +{ + "name": "healthchecks-io", + "displayName": "Healthchecks.io", + "version": "1.0.0", + "author": { + "name": "@adamkinniburgh", + "type": "community" + }, + "description": "Monitor your cron jobs and scheduled tasks with check status, availability and job run times.", + "category": "Monitoring", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": [ + "healthchecks", + "healthchecks.io", + "cron", + "cron job", + "heartbeat", + "scheduled task", + "dead man's switch", + "uptime", + "monitoring" + ], + "objectTypes": ["Check", "Tag"], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/Healthchecks.io/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/Healthchecks.io/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "{{ (serverUrl || 'https://healthchecks.io').trim().replace(/[/]+$/, '').replace(/[/]api([/]v[0-9]+)?$/i, '') }}/api/v3/", + "authMode": "none", + "headers": [ + { + "key": "X-Api-Key", + "value": "{{ apiKey.trim() }}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "queryArgs": [], + "ignoreCertificateErrors": "{{ typeof ignoreCertificateErrors !== 'undefined' && ignoreCertificateErrors === true }}" + } + } +} diff --git a/plugins/Healthchecks.io/v1/ui.json b/plugins/Healthchecks.io/v1/ui.json new file mode 100644 index 00000000..be7f0b6c --- /dev/null +++ b/plugins/Healthchecks.io/v1/ui.json @@ -0,0 +1,34 @@ +[ + { + "type": "password", + "name": "apiKey", + "label": "API key", + "placeholder": "Enter the Healthchecks.io project API key", + "help": "Create a key under **Project Settings → API Access**. A read-only key covers everything except the ping log.", + "validation": { + "required": true + } + }, + { + "type": "text", + "name": "serverUrl", + "label": "Server URL", + "defaultValue": "https://healthchecks.io", + "placeholder": "https://healthchecks.io", + "help": "Change only when connecting to a self-hosted Healthchecks instance.", + "validation": { + "required": true, + "pattern": { + "value": "^https?://[^ ]+$", + "message": "Enter the full address of the Healthchecks instance, e.g. https://healthchecks.example.com" + } + } + }, + { + "type": "checkbox", + "name": "ignoreCertificateErrors", + "label": "Ignore certificate errors", + "defaultValue": false, + "help": "Enable when connecting to a self-hosted instance that uses a self-signed or private CA certificate." + } +]