mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-18 23:34:04 +02:00
Merge 6eff6f9295
into 2fd4e1cc72
This commit is contained in:
commit
7a43727a8b
10 changed files with 248 additions and 6 deletions
18
db/knex_migrations/2025-06-27-0001-add-rtsp.js
Normal file
18
db/knex_migrations/2025-06-27-0001-add-rtsp.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Add new columns and alter 'manual_status' to smallint
|
||||||
|
// migration file: add_rtsp_fields_to_monitor.js
|
||||||
|
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.alterTable("monitor", function (table) {
|
||||||
|
table.string("rtsp_username");
|
||||||
|
table.string("rtsp_password");
|
||||||
|
table.string("rtsp_path");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.alterTable("monitor", function (table) {
|
||||||
|
table.dropColumn("rtsp_username");
|
||||||
|
table.dropColumn("rtsp_password");
|
||||||
|
table.dropColumn("rtsp_path");
|
||||||
|
});
|
||||||
|
};
|
23
package-lock.json
generated
23
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "uptime-kuma",
|
"name": "uptime-kuma",
|
||||||
"version": "2.0.0-beta.2",
|
"version": "2.0.0-beta.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "uptime-kuma",
|
"name": "uptime-kuma",
|
||||||
"version": "2.0.0-beta.2",
|
"version": "2.0.0-beta.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "~1.8.22",
|
"@grpc/grpc-js": "~1.8.22",
|
||||||
|
@ -75,6 +75,7 @@
|
||||||
"qs": "~6.10.4",
|
"qs": "~6.10.4",
|
||||||
"redbean-node": "~0.3.0",
|
"redbean-node": "~0.3.0",
|
||||||
"redis": "~4.5.1",
|
"redis": "~4.5.1",
|
||||||
|
"rtsp-client": "^1.4.5",
|
||||||
"semver": "~7.5.4",
|
"semver": "~7.5.4",
|
||||||
"socket.io": "~4.8.0",
|
"socket.io": "~4.8.0",
|
||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
|
@ -14850,6 +14851,16 @@
|
||||||
"rtlcss": "bin/rtlcss.js"
|
"rtlcss": "bin/rtlcss.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rtsp-client": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/rtsp-client/-/rtsp-client-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-21ZjCoGZdCPOTZOME1BzZ+OCXJIU6SQoGEAKwlbuVPU/jhAX9S8wTr+ZkBMR074xK28UfFx1KQvTV1/wECU3MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"www-authenticate": "^0.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/run-applescript": {
|
"node_modules/run-applescript": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
|
||||||
|
@ -17852,6 +17863,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/www-authenticate": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/www-authenticate/-/www-authenticate-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-8VkdLBJiBh5aXlJvcVaPykwSI//OA+Sxw7g84vIyCqoqlXtLupGNhyXxbgVuZ7g5ZS+lCJ4bTtcw/gJciqEuAg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-js": {
|
"node_modules/xml-js": {
|
||||||
"version": "1.6.11",
|
"version": "1.6.11",
|
||||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
|
|
@ -133,6 +133,7 @@
|
||||||
"qs": "~6.10.4",
|
"qs": "~6.10.4",
|
||||||
"redbean-node": "~0.3.0",
|
"redbean-node": "~0.3.0",
|
||||||
"redis": "~4.5.1",
|
"redis": "~4.5.1",
|
||||||
|
"rtsp-client": "^1.4.5",
|
||||||
"semver": "~7.5.4",
|
"semver": "~7.5.4",
|
||||||
"socket.io": "~4.8.0",
|
"socket.io": "~4.8.0",
|
||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
|
|
|
@ -198,6 +198,10 @@ class Monitor extends BeanModel {
|
||||||
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
|
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
|
||||||
rabbitmqUsername: this.rabbitmqUsername,
|
rabbitmqUsername: this.rabbitmqUsername,
|
||||||
rabbitmqPassword: this.rabbitmqPassword,
|
rabbitmqPassword: this.rabbitmqPassword,
|
||||||
|
rtspUsername: this.rtspUsername,
|
||||||
|
rtspPassword: this.rtspPassword,
|
||||||
|
rtspPath: this.rtspPath
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
68
server/monitor-types/rtsp.js
Normal file
68
server/monitor-types/rtsp.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
const { MonitorType } = require("./monitor-type");
|
||||||
|
const RTSPClient = require("rtsp-client");
|
||||||
|
const { log, UP, DOWN } = require("../../src/util");
|
||||||
|
|
||||||
|
class RtspMonitorType extends MonitorType {
|
||||||
|
name = "rtsp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async check(monitor, heartbeat, _server) {
|
||||||
|
const { rtspUsername, rtspPassword, hostname, port, rtspPath } = monitor;
|
||||||
|
|
||||||
|
// Construct RTSP URL
|
||||||
|
let url = `rtsp://${hostname}:${port}${rtspPath}`;
|
||||||
|
if (rtspUsername && rtspPassword !== undefined) {
|
||||||
|
url = `rtsp://${rtspUsername}:${rtspPassword}@${hostname}:${port}${rtspPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
if (!url || !url.startsWith("rtsp://")) {
|
||||||
|
heartbeat.status = DOWN;
|
||||||
|
heartbeat.msg = "Invalid RTSP URL";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new RTSPClient();
|
||||||
|
client.on("error", (err) => {
|
||||||
|
log.debug("monitor", `RTSP client emitted error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("monitor", `Connecting to RTSP URL: ${url}`);
|
||||||
|
await client.connect(url);
|
||||||
|
|
||||||
|
const res = await client.describe();
|
||||||
|
log.debug("monitor", `RTSP DESCRIBE response: ${JSON.stringify(res)}`);
|
||||||
|
|
||||||
|
const statusCode = res?.statusCode;
|
||||||
|
const statusMessage = res?.statusMessage || "Unknown";
|
||||||
|
|
||||||
|
if (statusCode === 200) {
|
||||||
|
heartbeat.status = UP;
|
||||||
|
heartbeat.msg = "RTSP stream is accessible";
|
||||||
|
} else if (statusCode === 503) {
|
||||||
|
heartbeat.status = DOWN;
|
||||||
|
heartbeat.msg = res.body?.reason || "Service Unavailable";
|
||||||
|
} else {
|
||||||
|
heartbeat.status = DOWN;
|
||||||
|
heartbeat.msg = `${statusCode} - ${statusMessage}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("monitor", `[${monitor.name}] RTSP check failed: ${error.message}`);
|
||||||
|
heartbeat.status = DOWN;
|
||||||
|
heartbeat.msg = `RTSP check failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
log.debug("monitor", `Error closing RTSP client: ${closeError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
RtspMonitorType,
|
||||||
|
};
|
|
@ -878,6 +878,9 @@ let needSetup = false;
|
||||||
bean.rabbitmqPassword = monitor.rabbitmqPassword;
|
bean.rabbitmqPassword = monitor.rabbitmqPassword;
|
||||||
bean.conditions = JSON.stringify(monitor.conditions);
|
bean.conditions = JSON.stringify(monitor.conditions);
|
||||||
bean.manual_status = monitor.manual_status;
|
bean.manual_status = monitor.manual_status;
|
||||||
|
bean.rtspUsername = monitor.rtspUsername;
|
||||||
|
bean.rtspPassword = monitor.rtspPassword;
|
||||||
|
bean.rtspPath = monitor.rtspPath;
|
||||||
|
|
||||||
// ping advanced options
|
// ping advanced options
|
||||||
bean.ping_numeric = monitor.ping_numeric;
|
bean.ping_numeric = monitor.ping_numeric;
|
||||||
|
|
|
@ -119,6 +119,7 @@ class UptimeKumaServer {
|
||||||
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
|
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
|
||||||
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
|
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
|
||||||
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
|
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
|
||||||
|
UptimeKumaServer.monitorTypeList["rtsp"] = new RtspMonitorType();
|
||||||
|
|
||||||
// Allow all CORS origins (polling) in development
|
// Allow all CORS origins (polling) in development
|
||||||
let cors = undefined;
|
let cors = undefined;
|
||||||
|
@ -560,4 +561,5 @@ const { SNMPMonitorType } = require("./monitor-types/snmp");
|
||||||
const { MongodbMonitorType } = require("./monitor-types/mongodb");
|
const { MongodbMonitorType } = require("./monitor-types/mongodb");
|
||||||
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
|
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
|
||||||
const { ManualMonitorType } = require("./monitor-types/manual");
|
const { ManualMonitorType } = require("./monitor-types/manual");
|
||||||
|
const { RtspMonitorType } = require("./monitor-types/rtsp");
|
||||||
const Monitor = require("./model/monitor");
|
const Monitor = require("./model/monitor");
|
||||||
|
|
|
@ -1125,5 +1125,9 @@
|
||||||
"Staged Tags for Batch Add": "Staged Tags for Batch Add",
|
"Staged Tags for Batch Add": "Staged Tags for Batch Add",
|
||||||
"Clear Form": "Clear Form",
|
"Clear Form": "Clear Form",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"Manual": "Manual"
|
"Manual": "Manual",
|
||||||
|
"RTSP Username": "RTSP Username",
|
||||||
|
"RTSP Password": "RTSP Password",
|
||||||
|
"RTSP Path": "RTSP Path",
|
||||||
|
"Path": "Path"
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,9 @@
|
||||||
<option v-if="!$root.info.isContainer" value="tailscale-ping">
|
<option v-if="!$root.info.isContainer" value="tailscale-ping">
|
||||||
Tailscale Ping
|
Tailscale Ping
|
||||||
</option>
|
</option>
|
||||||
|
<option value="rtsp">
|
||||||
|
RTSP
|
||||||
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
<i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
|
<i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
|
||||||
|
@ -300,7 +303,7 @@
|
||||||
|
|
||||||
<!-- Hostname -->
|
<!-- Hostname -->
|
||||||
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP only -->
|
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP only -->
|
||||||
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp'" class="my-3">
|
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'rtsp'" class="my-3">
|
||||||
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
|
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
|
||||||
<input
|
<input
|
||||||
id="hostname"
|
id="hostname"
|
||||||
|
@ -315,7 +318,7 @@
|
||||||
|
|
||||||
<!-- Port -->
|
<!-- Port -->
|
||||||
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP -->
|
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP -->
|
||||||
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp'" class="my-3">
|
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'smtp' || monitor.type === 'snmp' || monitor.type === 'rtsp'" class="my-3">
|
||||||
<label for="port" class="form-label">{{ $t("Port") }}</label>
|
<label for="port" class="form-label">{{ $t("Port") }}</label>
|
||||||
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
|
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
|
||||||
</div>
|
</div>
|
||||||
|
@ -515,6 +518,13 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="monitor.type === 'rtsp'">
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="rtspPath" class="form-label"> {{ $t("RTSP Path") }}</label>
|
||||||
|
<input id="rtspPath" v-model="monitor.rtspPath" :placeholder="$t('Path')" type="text" class="form-control">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="monitor.type === 'radius'">
|
<template v-if="monitor.type === 'radius'">
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label for="radius_username" class="form-label">Radius {{ $t("Username") }}</label>
|
<label for="radius_username" class="form-label">Radius {{ $t("Username") }}</label>
|
||||||
|
@ -1056,6 +1066,22 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="monitor.type === 'rtsp'">
|
||||||
|
<h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="rtspUsername" class="form-label">{{ $t("RTSP Username") }}</label>
|
||||||
|
<input
|
||||||
|
id="rtspUsername" v-model="monitor.rtspUsername" :placeholder="$t('Username')" type="text" class="form-control"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="rtspPassword" class="form-label">{{ $t("RTSP Password") }}</label>
|
||||||
|
<input id="rtspPassword" v-model="monitor.rtspPassword" :placeholder="$t('Password')" type="password" class="form-control">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- gRPC Options -->
|
<!-- gRPC Options -->
|
||||||
<template v-if="monitor.type === 'grpc-keyword' ">
|
<template v-if="monitor.type === 'grpc-keyword' ">
|
||||||
<!-- Proto service enable TLS -->
|
<!-- Proto service enable TLS -->
|
||||||
|
@ -1198,7 +1224,11 @@ const monitorDefaults = {
|
||||||
rabbitmqNodes: [],
|
rabbitmqNodes: [],
|
||||||
rabbitmqUsername: "",
|
rabbitmqUsername: "",
|
||||||
rabbitmqPassword: "",
|
rabbitmqPassword: "",
|
||||||
conditions: []
|
conditions: [],
|
||||||
|
rtspUsername: "",
|
||||||
|
rtspPassword: "",
|
||||||
|
rtspPath: ""
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
93
test/backend-test/test-rtsp.js
Normal file
93
test/backend-test/test-rtsp.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
const { describe, test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { RtspMonitorType } = require("../../server/monitor-types/rtsp");
|
||||||
|
const { UP, DOWN, PENDING } = require("../../src/util");
|
||||||
|
const RTSPClient = require("rtsp-client");
|
||||||
|
|
||||||
|
describe("RTSP Monitor", {
|
||||||
|
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
|
||||||
|
}, () => {
|
||||||
|
test("RTSP stream is accessible", async () => {
|
||||||
|
const rtspMonitor = new RtspMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
hostname: "localhost",
|
||||||
|
port: 8554,
|
||||||
|
rtspPath: "/teststream",
|
||||||
|
rtspUsername: "user",
|
||||||
|
rtspPassword: "pass",
|
||||||
|
name: "RTSP Test Monitor",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = {
|
||||||
|
msg: "",
|
||||||
|
status: PENDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
RTSPClient.prototype.connect = async () => {};
|
||||||
|
RTSPClient.prototype.describe = async () => ({
|
||||||
|
statusCode: 200,
|
||||||
|
statusMessage: "OK",
|
||||||
|
});
|
||||||
|
RTSPClient.prototype.close = async () => {};
|
||||||
|
|
||||||
|
await rtspMonitor.check(monitor, heartbeat, {});
|
||||||
|
assert.strictEqual(heartbeat.status, UP);
|
||||||
|
assert.strictEqual(heartbeat.msg, "RTSP stream is accessible");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RTSP stream is not accessible", async () => {
|
||||||
|
const rtspMonitor = new RtspMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
hostname: "localhost",
|
||||||
|
port: 9999,
|
||||||
|
rtspPath: "/teststream",
|
||||||
|
rtspUsername: "user",
|
||||||
|
rtspPassword: "pass",
|
||||||
|
name: "RTSP Test Monitor",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = {
|
||||||
|
msg: "",
|
||||||
|
status: PENDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
RTSPClient.prototype.connect = async () => {
|
||||||
|
throw new Error("Connection refused");
|
||||||
|
};
|
||||||
|
RTSPClient.prototype.close = async () => {};
|
||||||
|
|
||||||
|
await rtspMonitor.check(monitor, heartbeat, {});
|
||||||
|
assert.strictEqual(heartbeat.status, DOWN);
|
||||||
|
assert.match(heartbeat.msg, /RTSP check failed: Connection refused/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RTSP stream returns 503 error", async () => {
|
||||||
|
const rtspMonitor = new RtspMonitorType();
|
||||||
|
const monitor = {
|
||||||
|
hostname: "localhost",
|
||||||
|
port: 8554,
|
||||||
|
rtspPath: "/teststream",
|
||||||
|
rtspUsername: "user",
|
||||||
|
rtspPassword: "pass",
|
||||||
|
name: "RTSP Test Monitor",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = {
|
||||||
|
msg: "",
|
||||||
|
status: PENDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
RTSPClient.prototype.connect = async () => {};
|
||||||
|
RTSPClient.prototype.describe = async () => ({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: "Service Unavailable",
|
||||||
|
body: { reason: "Server overloaded" },
|
||||||
|
});
|
||||||
|
RTSPClient.prototype.close = async () => {};
|
||||||
|
|
||||||
|
await rtspMonitor.check(monitor, heartbeat, {});
|
||||||
|
assert.strictEqual(heartbeat.status, DOWN);
|
||||||
|
assert.strictEqual(heartbeat.msg, "Server overloaded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue