feat: Add 'local service' monitor type

This adds a new monitor type to check local services by executing a shell command. It also includes fixes for Prometheus errors when adding new tags and for the UI not updating when tags are changed.
This commit is contained in:
iotux 2025-12-09 14:19:38 +01:00
parent 883083f5c3
commit 7461bd296f
8 changed files with 225 additions and 39 deletions

View File

@ -13,6 +13,13 @@ const viteCompressionFilter = /\.(js|mjs|json|css|html|svg)$/i;
export default defineConfig({
server: {
port: 3000,
watch: {
ignored: [
"**/node_modules/**",
"**/dist/**",
"**/data/**",
],
},
},
define: {
"FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version),

View File

@ -0,0 +1,33 @@
const { settings } = require("../../server/util-server");
/**
* @param {import("knex").Knex} knex
*/
exports.up = async (knex) => {
await knex.schema.alterTable("monitor", (table) => {
table.string("local_service_command");
table.string("local_service_expected_output");
table.string("local_service_check_type").notNullable().defaultTo("keyword");
});
};
/**
* @param {import("knex").Knex} knex
*/
exports.down = async (knex) => {
if (await knex.schema.hasColumn("monitor", "local_service_command")) {
await knex.schema.alterTable("monitor", (table) => {
table.dropColumn("local_service_command");
});
}
if (await knex.schema.hasColumn("monitor", "local_service_expected_output")) {
await knex.schema.alterTable("monitor", (table) => {
table.dropColumn("local_service_expected_output");
});
}
if (await knex.schema.hasColumn("monitor", "local_service_check_type")) {
await knex.schema.alterTable("monitor", (table) => {
table.dropColumn("local_service_check_type");
});
}
};

View File

@ -148,6 +148,7 @@ class Monitor extends BeanModel {
httpBodyEncoding: this.httpBodyEncoding,
jsonPath: this.jsonPath,
expectedValue: this.expectedValue,
local_service_check_type: this.local_service_check_type,
kafkaProducerTopic: this.kafkaProducerTopic,
kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
kafkaProducerSsl: this.getKafkaProducerSsl(),
@ -201,6 +202,8 @@ class Monitor extends BeanModel {
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
rabbitmqUsername: this.rabbitmqUsername,
rabbitmqPassword: this.rabbitmqPassword,
local_service_command: this.local_service_command,
local_service_expected_output: this.local_service_expected_output,
};
}

View File

@ -0,0 +1,62 @@
const { MonitorType } = require("./monitor-type");
const { exec } = require("child_process");
const { DOWN, UP, log, evaluateJsonQuery } = require("../../src/util");
class LocalServiceMonitorType extends MonitorType {
name = "local-service";
description = "Checks if a local service is running by executing a command.";
async check(monitor, heartbeat, server) {
return new Promise((resolve, reject) => {
exec(monitor.local_service_command, async (error, stdout, stderr) => {
if (error) {
heartbeat.status = DOWN;
heartbeat.msg = `Error executing command: ${error.message}`;
reject(new Error(heartbeat.msg));
return;
}
const output = stdout.trim();
if (monitor.local_service_check_type === "keyword") {
if (monitor.local_service_expected_output) {
if (output.includes(monitor.local_service_expected_output)) {
heartbeat.status = UP;
heartbeat.msg = `OK - Output contains "${monitor.local_service_expected_output}"`;
resolve();
} else {
heartbeat.status = DOWN;
heartbeat.msg = `Output did not contain "${monitor.local_service_expected_output}"`;
reject(new Error(heartbeat.msg));
}
} else {
heartbeat.status = UP;
heartbeat.msg = "OK - Command executed successfully";
resolve();
}
} else if (monitor.local_service_check_type === "json-query") {
try {
const data = JSON.parse(output);
const { status, response } = await evaluateJsonQuery(data, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue);
if (status) {
heartbeat.status = UP;
heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`;
resolve();
} else {
throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`);
}
} catch (e) {
heartbeat.status = DOWN;
heartbeat.msg = e.message;
reject(e);
}
}
});
});
}
}
module.exports = {
LocalServiceMonitorType,
};

View File

@ -900,6 +900,9 @@ let needSetup = false;
bean.rabbitmqPassword = monitor.rabbitmqPassword;
bean.conditions = JSON.stringify(monitor.conditions);
bean.manual_status = monitor.manual_status;
bean.local_service_command = monitor.local_service_command;
bean.local_service_expected_output = monitor.local_service_expected_output;
bean.local_service_check_type = monitor.local_service_check_type;
// ping advanced options
bean.ping_numeric = monitor.ping_numeric;
@ -1173,6 +1176,8 @@ let needSetup = false;
bean.color = tag.color;
await R.store(bean);
await Prometheus.init();
callback({
ok: true,
tag: await bean.toJSON(),
@ -1248,6 +1253,8 @@ let needSetup = false;
value,
]);
await server.sendUpdateMonitorIntoList(socket, monitorID);
callback({
ok: true,
msg: "successAdded",
@ -1272,6 +1279,8 @@ let needSetup = false;
monitorID,
]);
await server.sendUpdateMonitorIntoList(socket, monitorID);
callback({
ok: true,
msg: "successEdited",
@ -1296,6 +1305,8 @@ let needSetup = false;
value,
]);
await server.sendUpdateMonitorIntoList(socket, monitorID);
callback({
ok: true,
msg: "successDeleted",

View File

@ -124,6 +124,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
UptimeKumaServer.monitorTypeList["local-service"] = new LocalServiceMonitorType();
// Allow all CORS origins (polling) in development
let cors = undefined;
@ -570,5 +571,6 @@ const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const { TCPMonitorType } = require("./monitor-types/tcp.js");
const { ManualMonitorType } = require("./monitor-types/manual");
const { RedisMonitorType } = require("./monitor-types/redis");
const { LocalServiceMonitorType } = require("./monitor-types/local-service");
const Monitor = require("./model/monitor");

View File

@ -1024,8 +1024,12 @@
"useRemoteBrowser": "Use a Remote Browser",
"deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?",
"GrafanaOncallUrl": "Grafana Oncall URL",
"Local Service": "Local Service",
"Browser Screenshot": "Browser Screenshot",
"Command": "Command",
"localServiceCommandDescription": "The command to execute. For example: `systemctl is-active mosquitto`",
"localServiceExpectedOutputDescription": "The expected output of the command. If the output contains this string, the monitor will be considered UP. Leave empty to only check the exit code.",
"Expected Output": "Expected Output",
"mongodbCommandDescription": "Run a MongoDB command against the database. For information about the available commands check out the {documentation}",
"wayToGetSevenIOApiKey": "Visit the dashboard under app.seven.io > developer > api key > the green add button",
"senderSevenIO": "Sending number or name",

View File

@ -98,6 +98,9 @@
<option value="redis">
Redis
</option>
<option value="local-service">
{{ $t("Local Service") }}
</option>
<option v-if="!$root.info.isContainer" value="tailscale-ping">
Tailscale Ping
</option>
@ -656,6 +659,7 @@
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL / Redis / MongoDB -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql' || monitor.type === 'redis' || monitor.type === 'mongodb'">
<div class="my-3">
@ -664,6 +668,63 @@
</div>
</template>
<template v-if="monitor.type === 'local-service'">
<div class="my-3">
<label for="local-service-command" class="form-label">{{ $t("Command") }}</label>
<input id="local-service-command" v-model="monitor.local_service_command" type="text" class="form-control" required>
<div class="form-text">
{{ $t("localServiceCommandDescription") }}
</div>
</div>
<div class="my-3">
<label for="local-service-check-type" class="form-label">{{ $t("Check Type") }}</label>
<select id="local-service-check-type" v-model="monitor.local_service_check_type" class="form-select" required>
<option value="keyword">{{ $t("Keyword") }}</option>
<option value="json-query">{{ $t("Json Query") }}</option>
</select>
</div>
<div v-if="monitor.local_service_check_type === 'keyword'" class="my-3">
<label for="local-service-expected-output" class="form-label">{{ $t("Expected Value") }}</label>
<input id="local-service-expected-output" v-model="monitor.local_service_expected_output" type="text" class="form-control">
<div class="form-text">
{{ $t("localServiceExpectedOutputDescription") }}
</div>
</div>
<div v-if="monitor.local_service_check_type === 'json-query'" class="my-3">
<div class="my-2">
<label for="jsonPath" class="form-label mb-0">{{ $t("Json Query Expression") }}</label>
<i18n-t tag="div" class="form-text mb-2" keypath="jsonQueryDescription">
<a href="https://jsonata.org/" target="_blank" rel="noopener noreferrer">jsonata.org</a>
<a href="https://try.jsonata.org/" target="_blank" rel="noopener noreferrer">{{ $t('playground') }}</a>
</i18n-t>
<input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control" placeholder="$" required>
</div>
<div class="d-flex align-items-start">
<div class="me-2">
<label for="json_path_operator" class="form-label">{{ $t("Condition") }}</label>
<select id="json_path_operator" v-model="monitor.jsonPathOperator" class="form-select me-3" required>
<option value=">">&gt;</option>
<option value=">=">&gt;=</option>
<option value="<">&lt;</option>
<option value="<=">&lt;=</option>
<option value="!=">&#33;=</option>
<option value="==">==</option>
<option value="contains">contains</option>
</select>
</div>
<div class="flex-grow-1">
<label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
<input v-if="monitor.jsonPathOperator !== 'contains' && monitor.jsonPathOperator !== '==' && monitor.jsonPathOperator !== '!='" id="expectedValue" v-model="monitor.expectedValue" type="number" class="form-control" required step=".01">
<input v-else id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control" required>
</div>
</div>
</div>
</template>
<template v-if="monitor.type === 'mysql'">
<div class="my-3">
<label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
@ -671,16 +732,16 @@
<HiddenInput id="mysql-password" v-model="monitor.radiusPassword" autocomplete="false"></HiddenInput>
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL -->
<!-- SQL Server / PostgreSQL / MySQL -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'">
<div class="my-3">
<label for="sqlQuery" class="form-label">{{ $t("Query") }}</label>
<textarea id="sqlQuery" v-model="monitor.databaseQuery" class="form-control" :placeholder="$t('Example:', [ 'SELECT 1' ])"></textarea>
</div>
</template>
<!-- MongoDB -->
<!-- MongoDB -->
<template v-if="monitor.type === 'mongodb'">
<div class="my-3">
<label for="mongodbCommand" class="form-label">{{ $t("Command") }}</label>
@ -705,8 +766,8 @@
<input id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control">
</div>
</template>
<!-- Conditions -->
<!-- Conditions -->
<EditMonitorConditions
v-if="supportsConditions && conditionVariables.length > 0"
v-model="monitor.conditions"
@ -738,8 +799,8 @@
{{ $t("minimumIntervalWarning") }}
</div>
</div>
<div class="my-3">
<div class="my-3">
<label for="maxRetries" class="form-label">{{ $t("Retries") }}</label>
<input id="maxRetries" v-model="monitor.maxretries" type="number" class="form-control" required min="0" step="1">
<div class="form-text">
@ -766,8 +827,8 @@
{{ $t("minimumIntervalWarning") }}
</div>
</div>
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP only -->
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP only -->
<div v-if="monitor.type === 'http' || monitor.type === 'json-query' || monitor.type === 'keyword' || monitor.type === 'ping' || monitor.type === 'rabbitmq' || monitor.type === 'snmp'" class="my-3">
<label for="timeout" class="form-label">
{{ monitor.type === 'ping' ? $t("pingGlobalTimeoutLabel") : $t("Request Timeout") }}
@ -785,8 +846,8 @@
</label>
<input id="resend-interval" v-model="monitor.resendInterval" type="number" class="form-control" required min="0" step="1">
</div>
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || (monitor.type === 'port' && ['starttls', 'secure'].includes(monitor.smtpSecurity))" class="my-3 form-check" :title="monitor.ignoreTls ? $t('ignoredTLSError') : ''">
<input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox" :disabled="monitor.ignoreTls">
@ -796,8 +857,8 @@
<div class="form-text">
</div>
</div>
<div v-if="monitor.type === 'websocket-upgrade' " class="my-3 form-check">
<div v-if="monitor.type === 'websocket-upgrade' " class="my-3 form-check">
<input id="wsIgnoreSecWebsocketAcceptHeader" v-model="monitor.wsIgnoreSecWebsocketAcceptHeader" class="form-check-input" type="checkbox">
<i18n-t tag="label" keypath="Ignore Sec-WebSocket-Accept header" class="form-check-label" for="wsIgnoreSecWebsocketAcceptHeader">
<code>Sec-Websocket-Accept</code>
@ -813,8 +874,8 @@
{{ monitor.type === "redis" ? $t("ignoreTLSErrorGeneral") : $t("ignoreTLSError") }}
</label>
</div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check">
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check">
<input id="cache-bust" v-model="monitor.cacheBust" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="cache-bust">
<i18n-t tag="label" keypath="cacheBusterParam" class="form-check-label" for="cache-bust">
@ -835,8 +896,8 @@
{{ $t("upsideDownModeDescription") }}
</div>
</div>
<div v-if="monitor.type === 'gamedig'" class="my-3 form-check">
<div v-if="monitor.type === 'gamedig'" class="my-3 form-check">
<input id="gamedig-guess-port" v-model="monitor.gamedigGivenPortOnly" :true-value="false" :false-value="true" class="form-check-input" type="checkbox">
<label class="form-check-label" for="gamedig-guess-port">
{{ $t("gamedigGuessPort") }}
@ -854,8 +915,8 @@
{{ $t("pingCountDescription") }}
</div>
</div>
<!-- Numeric Output -->
<!-- Numeric Output -->
<div v-if="monitor.type === 'ping'" class="my-3 form-check">
<input id="ping_numeric" v-model="monitor.ping_numeric" type="checkbox" class="form-check-input" :checked="monitor.ping_numeric">
<label class="form-check-label" for="ping_numeric">
@ -871,8 +932,8 @@
<label for="packet-size" class="form-label">{{ $t("Packet Size") }}</label>
<input id="packet-size" v-model="monitor.packetSize" type="number" class="form-control" required min="1" :max="65500" step="1">
</div>
<!-- per-request timeout -->
<!-- per-request timeout -->
<div v-if="monitor.type === 'ping'" class="my-3">
<label for="ping_per_request_timeout" class="form-label">{{ $t("pingPerRequestTimeoutLabel") }}</label>
<input id="ping_per_request_timeout" v-model="monitor.ping_per_request_timeout" type="number" class="form-control" required min="0" max="300" step="1">
@ -912,8 +973,8 @@
{{ $t("acceptedStatusCodesDescription") }}
</div>
</div>
<div class="my-3">
<div class="my-3">
<label for="ipFamily" class="form-label">{{ $t("Ip Family") }}</label>
<select id="ipFamily" v-model="monitor.ipFamily" class="form-select">
<option :value="null">{{ $t("auto-select") }}</option>
@ -941,8 +1002,8 @@
:action="() => $refs.createGroupDialog.show()"
/>
</div>
<!-- Description -->
<!-- Description -->
<div class="my-3">
<label for="description" class="form-label">{{ $t("Description") }}</label>
<input id="description" v-model="monitor.description" type="text" class="form-control">
@ -956,8 +1017,8 @@
<div class="col-md-6">
<div v-if="$root.isMobile" class="mt-3" />
<!-- Notifications -->
<!-- Notifications -->
<h2 class="mb-2">{{ $t("Notifications") }}</h2>
<p v-if="$root.notificationList.length === 0">
{{ $t("Not available, please setup.") }}
@ -977,8 +1038,8 @@
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
{{ $t("Setup Notification") }}
</button>
<!-- Proxies -->
<!-- Proxies -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query'">
<h2 class="mt-5 mb-2">{{ $t("Proxy") }}</h2>
<p v-if="$root.proxyList.length === 0">
@ -997,8 +1058,8 @@
{{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
<a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
</label>
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
@ -1109,8 +1170,8 @@
<label for="headers" class="form-label">{{ $t("Headers") }}</label>
<textarea id="headers" v-model="monitor.headers" class="form-control" :placeholder="headersPlaceholder"></textarea>
</div>
<!-- HTTP Auth -->
<!-- HTTP Auth -->
<h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>
<!-- Method -->
@ -1189,8 +1250,8 @@
<label for="basicauth-user" class="form-label">{{ $t("Username") }}</label>
<input id="basicauth-user" v-model="monitor.basic_auth_user" type="text" class="form-control" :placeholder="$t('Username')">
</div>
<div class="my-3">
<div class="my-3">
<label for="basicauth-pass" class="form-label">{{ $t("Password") }}</label>
<input id="basicauth-pass" v-model="monitor.basic_auth_pass" type="password" autocomplete="new-password" class="form-control" :placeholder="$t('Password')">
</div>
@ -1236,8 +1297,8 @@
{{ $t("grpcMethodDescription") }}
</div>
</div>
<!-- Proto data -->
<!-- Proto data -->
<div class="my-3">
<label for="protobuf" class="form-label">{{ $t("Proto Content") }}</label>
<textarea id="protobuf" v-model="monitor.grpcProtobuf" class="form-control" :placeholder="protoBufDataPlaceholder"></textarea>
@ -1359,7 +1420,10 @@ const monitorDefaults = {
rabbitmqNodes: [],
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: []
conditions: [],
local_service_command: "",
local_service_expected_output: "",
local_service_check_type: "keyword",
};
export default {