️ Improve compatibility of status checks

This commit is contained in:
Alicia Sykes
2026-07-25 18:36:13 +01:00
parent a6f9ca33b2
commit e85fe755c0
9 changed files with 114 additions and 56 deletions

View File

@@ -98,7 +98,7 @@ If the URL you are checking has an unsigned certificate, or is not using HTTPS,
If your service is online, but responds with a status code that is not in the 2xx range, then you can use `statusCheckAcceptCodes` to set an accepted status code.
If you get an error, like `Service Unavailable: Server resulted in a fatal error`, even when it's definitely online, this is most likely caused by missing the protocol. Don't forget to include `https://` (or whatever protocol) before the URL, and ensure that if needed, you've specified the port.
If you get an error, like `Service Unavailable: Server resulted in Invalid URL`, even when it's definitely online, this is most likely caused by missing the protocol. Don't forget to include `https://` (or whatever protocol) before the URL, and ensure that if needed, you've specified the port.
Running Dashy in HOST network mode, instead of BRIDGE will allow status check access to other services in HOST mode. For more info, see [#445](https://github.com/Lissy93/dashy/discussions/445).

View File

@@ -728,7 +728,7 @@ If the URL you are checking has an unsigned certificate, or is not using HTTPS,
If your service is online, but responds with a status code that is not in the 2xx range, then you can use `statusCheckAcceptCodes` to set an accepted status code.
If you get an error, like `Service Unavailable: Server resulted in a fatal error`, even when it's definitely online, this is most likely caused by missing the protocol. Don't forget to include `https://` (or whatever protocol) before the URL, and ensure that if needed, you've specified the port.
If you get an error, like `Service Unavailable: Server resulted in Invalid URL`, even when it's definitely online, this is most likely caused by missing the protocol. Don't forget to include `https://` (or whatever protocol) before the URL, and ensure that if needed, you've specified the port.
Running Dashy in HOST network mode, instead of BRIDGE will allow status check access to other services in HOST mode. For more info, see [#445](https://github.com/Lissy93/dashy/discussions/445).

View File

@@ -19,11 +19,11 @@ module.exports = (paramStr, render) => {
if (!paramStr || !paramStr.includes('=')) {
immediateError(render);
} else {
// Prepare the parameters, which are got from the URL
const params = new URLSearchParams(paramStr);
const host = decodeURIComponent(params.get('host'));
const count = Number(decodeURIComponent(params.get('count'))) || 2;
const timeout = Number(decodeURIComponent(params.get('timeout'))) || 2000;
// Get the url to check from query params
const params = new URLSearchParams(paramStr.slice(paramStr.indexOf('?') + 1));
const host = params.get('host') || '';
const count = Number(params.get('count')) || 2;
const timeout = Number(params.get('timeout')) || 2000;
if (!host || typeof host !== 'string') {
immediateError(render, 'Invalid host given for ping check.');
return;

View File

@@ -4,6 +4,7 @@
* endpoint, and then resolve the response status code, time taken, and short message
*/
const request = require('../utils/request');
const { validateTargetUrl } = request;
/* Determines if successful from the HTTP response code */
@@ -22,7 +23,7 @@ const makeMessageText = (data) => `${data.successStatus ? '✅' : '⚠️'} `
/* Makes human-readable response text for failed check */
const makeErrorMessage = (data) => `❌ Service Unavailable: ${data.hostname || 'Server'} `
+ `resulted in ${data.code || 'a fatal error'} ${data.errno ? `(${data.errno})` : ''}`;
+ `resulted in ${data.code || data.message || 'a fatal error'} ${data.errno ? `(${data.errno})` : ''}`;
const makeErrorMessage2 = (data) => '❌ Service Error - '
+ `${data.status} - ${data.statusText}`;
@@ -81,11 +82,10 @@ const makeRequest = (url, options, render) => {
const decodeHeaders = (maybeHeaders) => {
if (!maybeHeaders) return {};
const decodedHeaders = decodeURIComponent(maybeHeaders);
let parsedHeaders = {};
try {
parsedHeaders = JSON.parse(decodedHeaders);
} catch (e) { /* Not valid JSON, will just return false */ }
parsedHeaders = JSON.parse(maybeHeaders);
} catch (e) { /* Not valid JSON, will just return an empty object */ }
return parsedHeaders;
};
@@ -102,11 +102,11 @@ module.exports = (paramStr, render) => {
if (!paramStr || !paramStr.includes('=')) {
immediateError(render);
} else {
// Prepare the parameters, which are got from the URL
const params = new URLSearchParams(paramStr);
const url = decodeURIComponent(params.get('url'));
const acceptCodes = decodeURIComponent(params.get('acceptCodes'));
const maxRedirects = decodeURIComponent(params.get('maxRedirects')) || 0;
// Read the query params from req.url, ignoring its leading path and '?'
const params = new URLSearchParams(paramStr.slice(paramStr.indexOf('?') + 1));
const url = params.get('url') || '';
const acceptCodes = params.get('acceptCodes');
const maxRedirects = Number(params.get('maxRedirects')) || 5;
const headers = decodeHeaders(params.get('headers'));
const enableInsecure = !!params.get('enableInsecure');
if (!url || url === 'undefined') {

View File

@@ -50,4 +50,7 @@ const runCheck = async () => {
}
};
runCheck().catch(() => { /* never propagate — module-load must not throw */ });
// Skip version check when running tests
if (process.env.NODE_ENV !== 'test') {
runCheck().catch(() => { /* just chillin */ });
}

View File

@@ -58,9 +58,9 @@ export default {
},
/* Determine how often to re-fire status checks */
statusCheckInterval() {
let interval = this.item.statusCheckInterval || this.appConfig.statusCheckInterval;
let interval = this.item.statusCheckInterval ?? this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval > 300) interval = 300;
if (interval < 1) interval = 0;
return interval;
},
@@ -79,8 +79,7 @@ export default {
},
/* Determine how often to re-fire ping checks */
pingCheckInterval() {
let interval = this.item.pingCheckInterval;
if (!interval) interval = this.appConfig.pingCheckInterval;
let interval = this.item.pingCheckInterval ?? this.appConfig.pingCheckInterval;
if (!interval) return 0;
interval = Math.floor(interval);
if (interval < 1) return 0;
@@ -211,8 +210,8 @@ export default {
})
.catch(() => { // Something went very wrong.
this.statusResponse = {
statusText: 'Failed to make request',
statusSuccess: false,
successStatus: false,
message: 'Failed to make request',
};
});
},
@@ -221,8 +220,8 @@ export default {
if (!this.isPingCheckEnabled) return;
if (!this.pingCheckHost) {
this.pingResponse = {
statusText: 'Host not set or invalid',
statusSuccess: false,
successStatus: false,
message: 'Host not set or invalid',
};
} else {
if (this.pingResponse) this.pingResponse.successStatus = undefined; // Reset previous response, to show loading state
@@ -233,8 +232,8 @@ export default {
})
.catch(() => { // Something went very wrong.
this.pingResponse = {
statusText: 'Failed to make Ping request',
statusSuccess: false,
successStatus: false,
message: 'Failed to make Ping request',
};
});
}

View File

@@ -1336,6 +1336,12 @@
"default": 0,
"description": "If your service redirects to another page, and you would like status checks to follow redirects, then specify the maximum number of redirects here"
},
"statusCheckInterval": {
"title": "Item Status Check Interval",
"type": "number",
"default": 0,
"description": "How often (in seconds) to recheck this item's status. If set to 0, status is only checked on page load. Limited to a 5 minute maximum. Will override appConfig.statusCheckInterval."
},
"pingCheckEnabled": {
"title": "Item Ping Check Enabling",
"type": "boolean",

View File

@@ -261,13 +261,13 @@ describe('Computed: statusCheckInterval', () => {
expect(wrapper.vm.statusCheckInterval).toBe(15);
});
it('clamps to max 60', () => {
it('clamps to a 5 minute maximum', () => {
const { wrapper } = mountItem({
item: {
id: '1', title: 'X', url: '#', statusCheckInterval: 120,
id: '1', title: 'X', url: '#', statusCheckInterval: 600,
},
});
expect(wrapper.vm.statusCheckInterval).toBe(60);
expect(wrapper.vm.statusCheckInterval).toBe(300);
});
it('clamps values less than 1 to 0', () => {

View File

@@ -2,48 +2,98 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect } from 'vitest';
import http from 'http';
import {
describe, it, expect, beforeAll, afterAll,
} from 'vitest';
import request from 'supertest';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-status-test-'));
fs.writeFileSync(path.join(tmpDir, 'conf.yml'), 'pageInfo:\n title: Test\nsections: []\n');
process.env.USER_DATA_DIR = tmpDir;
const app = require('../../services/app');
let server;
let base;
// A small local server that plays out each status-check scenario
beforeAll(async () => {
server = http.createServer((req, res) => {
switch (req.url.split('?')[0]) {
case '/ok': res.writeHead(200); break;
case '/down': res.writeHead(503); break;
case '/teapot': res.writeHead(418); break;
case '/redirect': res.writeHead(302, { Location: '/ok' }); break;
case '/redirect-loop': res.writeHead(302, { Location: '/redirect-loop' }); break;
case '/needs-header': res.writeHead(req.headers['x-key'] ? 200 : 401); break;
default: res.writeHead(404);
}
res.end();
});
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
base = `http://127.0.0.1:${server.address().port}`;
});
afterAll(() => new Promise((resolve) => { server.close(resolve); }));
// Run a status check against a url and return the parsed result
const check = async (url, extra = '') => {
const res = await request(app).get(`/status-check/?&url=${encodeURIComponent(url)}${extra}`);
return JSON.parse(res.text);
};
describe('Status check', () => {
it('returns error for missing URL param', async () => {
const res = await request(app).get('/status-check/');
const body = JSON.parse(res.text);
expect(body.successStatus).toBe(false);
it('shows a service that returns 200 as online', async () => {
expect((await check(`${base}/ok`)).successStatus).toBe(true);
});
it('returns error for empty URL param', async () => {
const res = await request(app).get('/status-check/?&url=');
const body = JSON.parse(res.text);
expect(body.successStatus).toBe(false);
it('shows a service that returns an error code as offline', async () => {
expect((await check(`${base}/down`)).successStatus).toBe(false);
});
it('ignores POST requests', async () => {
const res = await request(app).post('/status-check/?&url=x');
expect(res.status).toBeLessThan(500);
it('treats a status code you have allow-listed as a success', async () => {
expect((await check(`${base}/teapot`, '&acceptCodes=418')).successStatus).toBe(true);
});
it('follows redirects when they are allowed', async () => {
expect((await check(`${base}/redirect`, '&maxRedirects=3')).successStatus).toBe(true);
});
it('stops following once the redirect limit is hit', async () => {
expect((await check(`${base}/redirect-loop`, '&maxRedirects=1')).successStatus).toBe(false);
});
it('sends custom headers along with the request', async () => {
// the % in the value would throw if the headers param were decoded twice
const headers = encodeURIComponent(JSON.stringify({ 'X-Key': '50% off' }));
expect((await check(`${base}/needs-header`, `&headers=${headers}`)).successStatus).toBe(true);
});
it('reports an unreachable host as offline', async () => {
expect((await check('http://127.0.0.1:1')).successStatus).toBe(false);
});
it('returns a clear error when no url is given', async () => {
const body = await check('');
expect(body.successStatus).toBe(false);
expect(body.message).toMatch(/Missing or Malformed/);
});
it('reads the url even without the leading ampersand', async () => {
const res = await request(app).get(`/status-check/?url=${encodeURIComponent(`${base}/ok`)}`);
expect(JSON.parse(res.text).successStatus).toBe(true);
});
});
describe('Ping check', () => {
it('returns error for missing URL param', async () => {
it('returns an error when no host is given', async () => {
const res = await request(app).get('/ping-check/?count=2');
expect(JSON.parse(res.text).successStatus).toBe(false);
});
it('returns an error for a request with no query', async () => {
const res = await request(app).get('/ping-check/');
const body = JSON.parse(res.text);
expect(body.successStatus).toBe(false);
});
it('returns error for empty URL param', async () => {
const res = await request(app).get('/ping-check/?&host=');
const body = JSON.parse(res.text);
expect(body.successStatus).toBe(false);
});
it('ignores POST requests', async () => {
const res = await request(app).post('/ping-check/?&host=localhost');
expect(res.status).toBeLessThan(500);
expect(JSON.parse(res.text).successStatus).toBe(false);
});
});