# Conflicts: # server/database.jspull/3040/head
commit
ed6b4e5ae5
@ -0,0 +1,6 @@
|
|||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
ALTER TABLE monitor
|
||||||
|
ADD parent INTEGER REFERENCES [monitor] ([id]) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
COMMIT
|
@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
|
|
||||||
const http = require("https"); // or 'https' for https:// URLs
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const platform = process.argv[2];
|
|
||||||
|
|
||||||
if (!platform) {
|
|
||||||
console.error("No platform??");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let arch = null;
|
|
||||||
|
|
||||||
if (platform === "linux/amd64") {
|
|
||||||
arch = "amd64";
|
|
||||||
} else if (platform === "linux/arm64") {
|
|
||||||
arch = "arm64";
|
|
||||||
} else if (platform === "linux/arm/v7") {
|
|
||||||
arch = "arm";
|
|
||||||
} else {
|
|
||||||
console.error("Invalid platform?? " + platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = fs.createWriteStream("cloudflared.deb");
|
|
||||||
get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download specified file
|
|
||||||
* @param {string} url URL to request
|
|
||||||
*/
|
|
||||||
function get(url) {
|
|
||||||
http.get(url, function (res) {
|
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
||||||
console.log("Redirect to " + res.headers.location);
|
|
||||||
get(res.headers.location);
|
|
||||||
} else if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
res.pipe(file);
|
|
||||||
|
|
||||||
res.on("end", function () {
|
|
||||||
console.log("Downloaded");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error(res.statusCode);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
@ -0,0 +1,9 @@
|
|||||||
|
// Check if docker is running
|
||||||
|
const { exec } = require("child_process");
|
||||||
|
|
||||||
|
exec("docker ps", (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Docker is not running. Please start docker and try again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
@ -1,24 +0,0 @@
|
|||||||
const childProcess = require("child_process");
|
|
||||||
|
|
||||||
class Git {
|
|
||||||
|
|
||||||
static clone(repoURL, cwd, targetDir = ".") {
|
|
||||||
let result = childProcess.spawnSync("git", [
|
|
||||||
"clone",
|
|
||||||
repoURL,
|
|
||||||
targetDir,
|
|
||||||
], {
|
|
||||||
cwd: cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(result.stderr.toString("utf-8"));
|
|
||||||
} else {
|
|
||||||
return result.stdout.toString("utf-8") + result.stderr.toString("utf-8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
Git,
|
|
||||||
};
|
|
@ -1,50 +0,0 @@
|
|||||||
const { parentPort, workerData } = require("worker_threads");
|
|
||||||
const Database = require("../database");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send message to parent process for logging
|
|
||||||
* since worker_thread does not have access to stdout, this is used
|
|
||||||
* instead of console.log()
|
|
||||||
* @param {any} any The message to log
|
|
||||||
*/
|
|
||||||
const log = function (any) {
|
|
||||||
if (parentPort) {
|
|
||||||
parentPort.postMessage(any);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exit the worker process
|
|
||||||
* @param {number} error The status code to exit
|
|
||||||
*/
|
|
||||||
const exit = function (error) {
|
|
||||||
if (error && error !== 0) {
|
|
||||||
process.exit(error);
|
|
||||||
} else {
|
|
||||||
if (parentPort) {
|
|
||||||
parentPort.postMessage("done");
|
|
||||||
} else {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Connects to the database */
|
|
||||||
const connectDb = async function () {
|
|
||||||
const dbPath = path.join(
|
|
||||||
process.env.DATA_DIR || workerData["data-dir"] || "./data/"
|
|
||||||
);
|
|
||||||
|
|
||||||
Database.init({
|
|
||||||
"data-dir": dbPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Database.connect();
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
log,
|
|
||||||
exit,
|
|
||||||
connectDb,
|
|
||||||
};
|
|
@ -0,0 +1,212 @@
|
|||||||
|
const { MonitorType } = require("./monitor-type");
|
||||||
|
const { chromium } = require("playwright-core");
|
||||||
|
const { UP, log } = require("../../src/util");
|
||||||
|
const { Settings } = require("../settings");
|
||||||
|
const commandExistsSync = require("command-exists").sync;
|
||||||
|
const childProcess = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
const Database = require("../database");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
let allowedList = [];
|
||||||
|
let lastAutoDetectChromeExecutable = null;
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
allowedList.push(process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||||
|
allowedList.push(process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||||
|
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||||
|
|
||||||
|
// Allow Chromium too
|
||||||
|
allowedList.push(process.env.LOCALAPPDATA + "\\Chromium\\Application\\chrome.exe");
|
||||||
|
allowedList.push(process.env.PROGRAMFILES + "\\Chromium\\Application\\chrome.exe");
|
||||||
|
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Chromium\\Application\\chrome.exe");
|
||||||
|
|
||||||
|
// For Loop A to Z
|
||||||
|
for (let i = 65; i <= 90; i++) {
|
||||||
|
let drive = String.fromCharCode(i);
|
||||||
|
allowedList.push(drive + ":\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
|
||||||
|
allowedList.push(drive + ":\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (process.platform === "linux") {
|
||||||
|
allowedList = [
|
||||||
|
"chromium",
|
||||||
|
"chromium-browser",
|
||||||
|
"google-chrome",
|
||||||
|
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
];
|
||||||
|
} else if (process.platform === "darwin") {
|
||||||
|
// TODO: Generated by GitHub Copilot, but not sure if it's correct
|
||||||
|
allowedList = [
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("chrome", allowedList);
|
||||||
|
|
||||||
|
async function isAllowedChromeExecutable(executablePath) {
|
||||||
|
console.log(config.args);
|
||||||
|
if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the executablePath is in the list of allowed executables
|
||||||
|
return allowedList.includes(executablePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBrowser() {
|
||||||
|
if (!browser) {
|
||||||
|
let executablePath = await Settings.get("chromeExecutable");
|
||||||
|
|
||||||
|
executablePath = await prepareChromeExecutable(executablePath);
|
||||||
|
|
||||||
|
browser = await chromium.launch({
|
||||||
|
//headless: false,
|
||||||
|
executablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareChromeExecutable(executablePath) {
|
||||||
|
// Special code for using the playwright_chromium
|
||||||
|
if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
|
||||||
|
// Set to undefined = use playwright_chromium
|
||||||
|
executablePath = undefined;
|
||||||
|
} else if (!executablePath) {
|
||||||
|
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
|
||||||
|
executablePath = "/usr/bin/chromium";
|
||||||
|
|
||||||
|
// Install chromium in container via apt install
|
||||||
|
if ( !commandExistsSync(executablePath)) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
log.info("Chromium", "Installing Chromium...");
|
||||||
|
let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
|
||||||
|
|
||||||
|
// On exit
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
log.info("Chromium", "apt install chromium exited with code " + code);
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
log.info("Chromium", "Installed Chromium");
|
||||||
|
let version = childProcess.execSync(executablePath + " --version").toString("utf8");
|
||||||
|
log.info("Chromium", "Chromium version: " + version);
|
||||||
|
resolve();
|
||||||
|
} else if (code === 100) {
|
||||||
|
reject(new Error("Installing Chromium, please wait..."));
|
||||||
|
} else {
|
||||||
|
reject(new Error("apt install chromium failed with code " + code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
executablePath = findChrome(allowedList);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User specified a path
|
||||||
|
// Check if the executablePath is in the list of allowed
|
||||||
|
if (!await isAllowedChromeExecutable(executablePath)) {
|
||||||
|
throw new Error("This Chromium executable path is not allowed by default. If you are sure this is safe, please add an environment variable UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1 to allow it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return executablePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChrome(executables) {
|
||||||
|
// Use the last working executable, so we don't have to search for it again
|
||||||
|
if (lastAutoDetectChromeExecutable) {
|
||||||
|
if (commandExistsSync(lastAutoDetectChromeExecutable)) {
|
||||||
|
return lastAutoDetectChromeExecutable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let executable of executables) {
|
||||||
|
if (commandExistsSync(executable)) {
|
||||||
|
lastAutoDetectChromeExecutable = executable;
|
||||||
|
return executable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetChrome() {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if the chrome executable is valid and return the version
|
||||||
|
* @param executablePath
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function testChrome(executablePath) {
|
||||||
|
try {
|
||||||
|
executablePath = await prepareChromeExecutable(executablePath);
|
||||||
|
|
||||||
|
log.info("Chromium", "Testing Chromium executable: " + executablePath);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath,
|
||||||
|
});
|
||||||
|
const version = browser.version();
|
||||||
|
await browser.close();
|
||||||
|
return version;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class RealBrowserMonitorType extends MonitorType {
|
||||||
|
|
||||||
|
name = "real-browser";
|
||||||
|
|
||||||
|
async check(monitor, heartbeat, server) {
|
||||||
|
const browser = await getBrowser();
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const res = await page.goto(monitor.url, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
timeout: monitor.interval * 1000 * 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(Database.screenshotDir, filename),
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
if (res.status() >= 200 && res.status() < 400) {
|
||||||
|
heartbeat.status = UP;
|
||||||
|
heartbeat.msg = res.status();
|
||||||
|
|
||||||
|
const timing = res.request().timing();
|
||||||
|
heartbeat.ping = timing.responseEnd;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.status() + "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
RealBrowserMonitorType,
|
||||||
|
testChrome,
|
||||||
|
resetChrome,
|
||||||
|
};
|
@ -1,13 +0,0 @@
|
|||||||
class Plugin {
|
|
||||||
async load() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async unload() {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
Plugin,
|
|
||||||
};
|
|
@ -1,256 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const { log } = require("../src/util");
|
|
||||||
const path = require("path");
|
|
||||||
const axios = require("axios");
|
|
||||||
const { Git } = require("./git");
|
|
||||||
const childProcess = require("child_process");
|
|
||||||
|
|
||||||
class PluginsManager {
|
|
||||||
|
|
||||||
static disable = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin List
|
|
||||||
* @type {PluginWrapper[]}
|
|
||||||
*/
|
|
||||||
pluginList = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugins Dir
|
|
||||||
*/
|
|
||||||
pluginsDir;
|
|
||||||
|
|
||||||
server;
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {UptimeKumaServer} server
|
|
||||||
*/
|
|
||||||
constructor(server) {
|
|
||||||
this.server = server;
|
|
||||||
|
|
||||||
if (!PluginsManager.disable) {
|
|
||||||
this.pluginsDir = "./data/plugins/";
|
|
||||||
|
|
||||||
if (! fs.existsSync(this.pluginsDir)) {
|
|
||||||
fs.mkdirSync(this.pluginsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("plugin", "Scanning plugin directory");
|
|
||||||
let list = fs.readdirSync(this.pluginsDir);
|
|
||||||
|
|
||||||
this.pluginList = [];
|
|
||||||
for (let item of list) {
|
|
||||||
this.loadPlugin(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
log.warn("PLUGIN", "Skip scanning plugin directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a Plugin
|
|
||||||
*/
|
|
||||||
async loadPlugin(name) {
|
|
||||||
log.info("plugin", "Load " + name);
|
|
||||||
let plugin = new PluginWrapper(this.server, this.pluginsDir + name);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await plugin.load();
|
|
||||||
this.pluginList.push(plugin);
|
|
||||||
} catch (e) {
|
|
||||||
log.error("plugin", "Failed to load plugin: " + this.pluginsDir + name);
|
|
||||||
log.error("plugin", "Reason: " + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a Plugin
|
|
||||||
* @param {string} repoURL Git repo url
|
|
||||||
* @param {string} name Directory name, also known as plugin unique name
|
|
||||||
*/
|
|
||||||
downloadPlugin(repoURL, name) {
|
|
||||||
if (fs.existsSync(this.pluginsDir + name)) {
|
|
||||||
log.info("plugin", "Plugin folder already exists? Removing...");
|
|
||||||
fs.rmSync(this.pluginsDir + name, {
|
|
||||||
recursive: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
log.info("plugin", "Installing plugin: " + name + " " + repoURL);
|
|
||||||
let result = Git.clone(repoURL, this.pluginsDir, name);
|
|
||||||
log.info("plugin", "Install result: " + result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a plugin
|
|
||||||
* @param {string} name
|
|
||||||
*/
|
|
||||||
async removePlugin(name) {
|
|
||||||
log.info("plugin", "Removing plugin: " + name);
|
|
||||||
for (let plugin of this.pluginList) {
|
|
||||||
if (plugin.info.name === name) {
|
|
||||||
await plugin.unload();
|
|
||||||
|
|
||||||
// Delete the plugin directory
|
|
||||||
fs.rmSync(this.pluginsDir + name, {
|
|
||||||
recursive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pluginList.splice(this.pluginList.indexOf(plugin), 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.warn("plugin", "Plugin not found: " + name);
|
|
||||||
throw new Error("Plugin not found: " + name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Update a plugin
|
|
||||||
* Only available for plugins which were downloaded from the official list
|
|
||||||
* @param pluginID
|
|
||||||
*/
|
|
||||||
updatePlugin(pluginID) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the plugin list from server + local installed plugin list
|
|
||||||
* Item will be merged if the `name` is the same.
|
|
||||||
* @returns {Promise<[]>}
|
|
||||||
*/
|
|
||||||
async fetchPluginList() {
|
|
||||||
let remotePluginList;
|
|
||||||
try {
|
|
||||||
const res = await axios.get("https://uptime.kuma.pet/c/plugins.json");
|
|
||||||
remotePluginList = res.data.pluginList;
|
|
||||||
} catch (e) {
|
|
||||||
log.error("plugin", "Failed to fetch plugin list: " + e.message);
|
|
||||||
remotePluginList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let plugin of this.pluginList) {
|
|
||||||
let find = false;
|
|
||||||
// Try to merge
|
|
||||||
for (let remotePlugin of remotePluginList) {
|
|
||||||
if (remotePlugin.name === plugin.info.name) {
|
|
||||||
find = true;
|
|
||||||
remotePlugin.installed = true;
|
|
||||||
remotePlugin.name = plugin.info.name;
|
|
||||||
remotePlugin.fullName = plugin.info.fullName;
|
|
||||||
remotePlugin.description = plugin.info.description;
|
|
||||||
remotePlugin.version = plugin.info.version;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local plugin
|
|
||||||
if (!find) {
|
|
||||||
plugin.info.local = true;
|
|
||||||
remotePluginList.push(plugin.info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort Installed first, then sort by name
|
|
||||||
return remotePluginList.sort((a, b) => {
|
|
||||||
if (a.installed === b.installed) {
|
|
||||||
if (a.fullName < b.fullName) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.fullName > b.fullName) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} else if (a.installed) {
|
|
||||||
return -1;
|
|
||||||
} else {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PluginWrapper {
|
|
||||||
|
|
||||||
server = undefined;
|
|
||||||
pluginDir = undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be an `new-able` class.
|
|
||||||
* @type {function}
|
|
||||||
*/
|
|
||||||
pluginClass = undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @type {Plugin}
|
|
||||||
*/
|
|
||||||
object = undefined;
|
|
||||||
info = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {UptimeKumaServer} server
|
|
||||||
* @param {string} pluginDir
|
|
||||||
*/
|
|
||||||
constructor(server, pluginDir) {
|
|
||||||
this.server = server;
|
|
||||||
this.pluginDir = pluginDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
let indexFile = this.pluginDir + "/index.js";
|
|
||||||
let packageJSON = this.pluginDir + "/package.json";
|
|
||||||
|
|
||||||
log.info("plugin", "Installing dependencies");
|
|
||||||
|
|
||||||
if (fs.existsSync(indexFile)) {
|
|
||||||
// Install dependencies
|
|
||||||
let result = childProcess.spawnSync("npm", [ "install" ], {
|
|
||||||
cwd: this.pluginDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PLAYWRIGHT_BROWSERS_PATH: "../../browsers", // Special handling for read-browser-monitor
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.stdout) {
|
|
||||||
log.info("plugin", "Install dependencies result: " + result.stdout.toString("utf-8"));
|
|
||||||
} else {
|
|
||||||
log.warn("plugin", "Install dependencies result: no output");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pluginClass = require(path.join(process.cwd(), indexFile));
|
|
||||||
|
|
||||||
let pluginClassType = typeof this.pluginClass;
|
|
||||||
|
|
||||||
if (pluginClassType === "function") {
|
|
||||||
this.object = new this.pluginClass(this.server);
|
|
||||||
await this.object.load();
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid plugin, it does not export a class");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(packageJSON)) {
|
|
||||||
this.info = require(path.join(process.cwd(), packageJSON));
|
|
||||||
} else {
|
|
||||||
this.info.fullName = this.pluginDir;
|
|
||||||
this.info.name = "[unknown]";
|
|
||||||
this.info.version = "[unknown-version]";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.info.installed = true;
|
|
||||||
log.info("plugin", `${this.info.fullName} v${this.info.version} loaded`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async unload() {
|
|
||||||
await this.object.unload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PluginsManager,
|
|
||||||
PluginWrapper
|
|
||||||
};
|
|
@ -1,69 +0,0 @@
|
|||||||
const { checkLogin } = require("../util-server");
|
|
||||||
const { PluginsManager } = require("../plugins-manager");
|
|
||||||
const { log } = require("../../src/util.js");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handlers for plugins
|
|
||||||
* @param {Socket} socket Socket.io instance
|
|
||||||
* @param {UptimeKumaServer} server
|
|
||||||
*/
|
|
||||||
module.exports.pluginsHandler = (socket, server) => {
|
|
||||||
|
|
||||||
const pluginManager = server.getPluginManager();
|
|
||||||
|
|
||||||
// Get Plugin List
|
|
||||||
socket.on("getPluginList", async (callback) => {
|
|
||||||
try {
|
|
||||||
checkLogin(socket);
|
|
||||||
|
|
||||||
log.debug("plugin", "PluginManager.disable: " + PluginsManager.disable);
|
|
||||||
|
|
||||||
if (PluginsManager.disable) {
|
|
||||||
throw new Error("Plugin Disabled: In order to enable plugin feature, you need to use the default data directory: ./data/");
|
|
||||||
}
|
|
||||||
|
|
||||||
let pluginList = await pluginManager.fetchPluginList();
|
|
||||||
callback({
|
|
||||||
ok: true,
|
|
||||||
pluginList,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.warn("plugin", "Error: " + error.message);
|
|
||||||
callback({
|
|
||||||
ok: false,
|
|
||||||
msg: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("installPlugin", async (repoURL, name, callback) => {
|
|
||||||
try {
|
|
||||||
checkLogin(socket);
|
|
||||||
pluginManager.downloadPlugin(repoURL, name);
|
|
||||||
await pluginManager.loadPlugin(name);
|
|
||||||
callback({
|
|
||||||
ok: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
callback({
|
|
||||||
ok: false,
|
|
||||||
msg: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("uninstallPlugin", async (name, callback) => {
|
|
||||||
try {
|
|
||||||
checkLogin(socket);
|
|
||||||
await pluginManager.removePlugin(name);
|
|
||||||
callback({
|
|
||||||
ok: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
callback({
|
|
||||||
ok: false,
|
|
||||||
msg: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="BadgeGeneratorModal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
{{ $t("Badge Generator", [monitor.name]) }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="type" class="form-label">{{ $t("Badge Type") }}</label>
|
||||||
|
<select id="type" v-model="badge.type" class="form-select">
|
||||||
|
<option value="status">status</option>
|
||||||
|
<option value="uptime">uptime</option>
|
||||||
|
<option value="ping">ping</option>
|
||||||
|
<option value="avg-response">avg-response</option>
|
||||||
|
<option value="cert-exp">cert-exp</option>
|
||||||
|
<option value="response">response</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('duration') " class="mb-3">
|
||||||
|
<label for="duration" class="form-label">{{ $t("Badge Duration") }}</label>
|
||||||
|
<input id="duration" v-model="badge.duration" type="number" min="0" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('label') " class="mb-3">
|
||||||
|
<label for="label" class="form-label">{{ $t("Badge Label") }}</label>
|
||||||
|
<input id="label" v-model="badge.label" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('prefix') " class="mb-3">
|
||||||
|
<label for="prefix" class="form-label">{{ $t("Badge Prefix") }}</label>
|
||||||
|
<input id="prefix" v-model="badge.prefix" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('suffix') " class="mb-3">
|
||||||
|
<label for="suffix" class="form-label">{{ $t("Badge Suffix") }}</label>
|
||||||
|
<input id="suffix" v-model="badge.suffix" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelColor') " class="mb-3">
|
||||||
|
<label for="labelColor" class="form-label">{{ $t("Badge Label Color") }}</label>
|
||||||
|
<input id="labelColor" v-model="badge.labelColor" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('color') " class="mb-3">
|
||||||
|
<label for="color" class="form-label">{{ $t("Badge Color") }}</label>
|
||||||
|
<input id="color" v-model="badge.color" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelPrefix') " class="mb-3">
|
||||||
|
<label for="labelPrefix" class="form-label">{{ $t("Badge Label Prefix") }}</label>
|
||||||
|
<input id="labelPrefix" v-model="badge.labelPrefix" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelSuffix') " class="mb-3">
|
||||||
|
<label for="labelSuffix" class="form-label">{{ $t("Badge Label Suffix") }}</label>
|
||||||
|
<input id="labelSuffix" v-model="badge.labelSuffix" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('upColor') " class="mb-3">
|
||||||
|
<label for="upColor" class="form-label">{{ $t("Badge Up Color") }}</label>
|
||||||
|
<input id="upColor" v-model="badge.upColor" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downColor') " class="mb-3">
|
||||||
|
<label for="downColor" class="form-label">{{ $t("Badge Down Color") }}</label>
|
||||||
|
<input id="downColor" v-model="badge.downColor" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('pendingColor') " class="mb-3">
|
||||||
|
<label for="pendingColor" class="form-label">{{ $t("Badge Pending Color") }}</label>
|
||||||
|
<input id="pendingColor" v-model="badge.pendingColor" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('maintenanceColor') " class="mb-3">
|
||||||
|
<label for="maintenanceColor" class="form-label">{{ $t("Badge Maintenance Color") }}</label>
|
||||||
|
<input id="maintenanceColor" v-model="badge.maintenanceColor" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnColor') " class="mb-3">
|
||||||
|
<label for="warnColor" class="form-label">{{ $t("Badge Warn Color") }}</label>
|
||||||
|
<input id="warnColor" v-model="badge.warnColor" type="number" min="0" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnDays') " class="mb-3">
|
||||||
|
<label for="warnDays" class="form-label">{{ $t("Badge Warn Days") }}</label>
|
||||||
|
<input id="warnDays" v-model="badge.warnDays" type="number" min="0" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downDays') " class="mb-3">
|
||||||
|
<label for="downDays" class="form-label">{{ $t("Badge Down Days") }}</label>
|
||||||
|
<input id="downDays" v-model="badge.downDays" type="number" min="0" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="style" class="form-label">{{ $t("Badge Style") }}</label>
|
||||||
|
<select id="style" v-model="badge.style" class="form-select">
|
||||||
|
<option value="plastic">plastic</option>
|
||||||
|
<option value="flat">flat</option>
|
||||||
|
<option value="flat-square">flat-square</option>
|
||||||
|
<option value="for-the-badge">for-the-badge</option>
|
||||||
|
<option value="social">social</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="value" class="form-label">{{ $t("Badge value (For Testing only.)") }}</label>
|
||||||
|
<input id="value" v-model="badge.value" type="text" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="push-url" class="form-label">{{ $t("Badge URL") }}</label>
|
||||||
|
<CopyableInput id="push-url" v-model="badgeURL" type="url" disabled="disabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal">
|
||||||
|
{{ $t("Close") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
import CopyableInput from "./CopyableInput.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
CopyableInput
|
||||||
|
},
|
||||||
|
props: {},
|
||||||
|
emits: [],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
model: null,
|
||||||
|
processing: false,
|
||||||
|
monitor: {
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
type: "status",
|
||||||
|
duration: null,
|
||||||
|
label: null,
|
||||||
|
prefix: null,
|
||||||
|
suffix: null,
|
||||||
|
labelColor: null,
|
||||||
|
color: null,
|
||||||
|
labelPrefix: null,
|
||||||
|
labelSuffix: null,
|
||||||
|
upColor: null,
|
||||||
|
downColor: null,
|
||||||
|
pendingColor: null,
|
||||||
|
maintenanceColor: null,
|
||||||
|
warnColor: null,
|
||||||
|
warnDays: null,
|
||||||
|
downDays: null,
|
||||||
|
style: "flat",
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
status: [
|
||||||
|
"upLabel",
|
||||||
|
"downLabel",
|
||||||
|
"pendingLabel",
|
||||||
|
"maintenanceLabel",
|
||||||
|
"upColor",
|
||||||
|
"downColor",
|
||||||
|
"pendingColor",
|
||||||
|
"maintenanceColor",
|
||||||
|
],
|
||||||
|
uptime: [
|
||||||
|
"duration",
|
||||||
|
"labelPrefix",
|
||||||
|
"labelSuffix",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"color",
|
||||||
|
"labelColor",
|
||||||
|
],
|
||||||
|
ping: [
|
||||||
|
"duration",
|
||||||
|
"labelPrefix",
|
||||||
|
"labelSuffix",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"color",
|
||||||
|
"labelColor",
|
||||||
|
],
|
||||||
|
"avg-response": [
|
||||||
|
"duration",
|
||||||
|
"labelPrefix",
|
||||||
|
"labelSuffix",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"color",
|
||||||
|
"labelColor",
|
||||||
|
],
|
||||||
|
"cert-exp": [
|
||||||
|
"labelPrefix",
|
||||||
|
"labelSuffix",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"upColor",
|
||||||
|
"warnColor",
|
||||||
|
"downColor",
|
||||||
|
"warnDays",
|
||||||
|
"downDays",
|
||||||
|
"labelColor",
|
||||||
|
],
|
||||||
|
response: [
|
||||||
|
"labelPrefix",
|
||||||
|
"labelSuffix",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"color",
|
||||||
|
"labelColor",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
badgeURL() {
|
||||||
|
if (!this.monitor.id || !this.badge.type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let badgeURL = this.$root.baseURL + "/api/badge/" + this.monitor.id + "/" + this.badge.type;
|
||||||
|
|
||||||
|
let parameterList = {};
|
||||||
|
|
||||||
|
for (let parameter of this.parameters[this.badge.type] || []) {
|
||||||
|
if (parameter === "duration" && this.badge.duration) {
|
||||||
|
badgeURL += "/" + this.badge.duration;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.badge[parameter]) {
|
||||||
|
parameterList[parameter] = this.badge[parameter];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let parameter of [ "label", "style", "value" ]) {
|
||||||
|
if (parameter === "style" && this.badge.style === "flat") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.badge[parameter]) {
|
||||||
|
parameterList[parameter] = this.badge[parameter];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(parameterList).length > 0) {
|
||||||
|
return badgeURL + "?" + new URLSearchParams(parameterList);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badgeURL;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.BadgeGeneratorModal = new Modal(this.$refs.BadgeGeneratorModal);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Setting monitor
|
||||||
|
* @param {number} monitorId ID of monitor
|
||||||
|
* @param {string} monitorName Name of monitor
|
||||||
|
*/
|
||||||
|
show(monitorId, monitorName) {
|
||||||
|
this.monitor = {
|
||||||
|
id: monitorId,
|
||||||
|
name: monitorName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.BadgeGeneratorModal.show();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
.modal-dialog .form-text, .modal-dialog p {
|
||||||
|
color: $dark-font-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ 'disabled': ! monitor.active }">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-9 col-md-8 small-padding" :class="{ 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
|
||||||
|
<div class="info" :style="depthMargin">
|
||||||
|
<Uptime :monitor="monitor" type="24" :pill="true" />
|
||||||
|
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
|
||||||
|
<font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" />
|
||||||
|
</span>
|
||||||
|
{{ monitorName }}
|
||||||
|
</div>
|
||||||
|
<div class="tags">
|
||||||
|
<Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-md-4">
|
||||||
|
<HeartbeatBar size="small" :monitor-id="monitor.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
|
||||||
|
<div class="col-12 bottom-style">
|
||||||
|
<HeartbeatBar size="small" :monitor-id="monitor.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<transition name="slide-fade-up">
|
||||||
|
<div v-if="!isCollapsed" class="childs">
|
||||||
|
<MonitorListItem v-for="(item, index) in sortedChildMonitorList" :key="index" :monitor="item" :isSearch="isSearch" :depth="depth + 1" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import HeartbeatBar from "../components/HeartbeatBar.vue";
|
||||||
|
import Tag from "../components/Tag.vue";
|
||||||
|
import Uptime from "../components/Uptime.vue";
|
||||||
|
import { getMonitorRelativeURL } from "../util.ts";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "MonitorListItem",
|
||||||
|
components: {
|
||||||
|
Uptime,
|
||||||
|
HeartbeatBar,
|
||||||
|
Tag,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
/** Monitor this represents */
|
||||||
|
monitor: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/** If the user is currently searching */
|
||||||
|
isSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/** How many ancestors are above this monitor */
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isCollapsed: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sortedChildMonitorList() {
|
||||||
|
let result = Object.values(this.$root.monitorList);
|
||||||
|
|
||||||
|
result = result.filter(childMonitor => childMonitor.parent === this.monitor.id);
|
||||||
|
|
||||||
|
result.sort((m1, m2) => {
|
||||||
|
|
||||||
|
if (m1.active !== m2.active) {
|
||||||
|
if (m1.active === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m2.active === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m1.weight !== m2.weight) {
|
||||||
|
if (m1.weight > m2.weight) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m1.weight < m2.weight) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m1.name.localeCompare(m2.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
hasChildren() {
|
||||||
|
return this.sortedChildMonitorList.length > 0;
|
||||||
|
},
|
||||||
|
depthMargin() {
|
||||||
|
return {
|
||||||
|
marginLeft: `${31 * this.depth}px`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
monitorName() {
|
||||||
|
if (this.isSearch) {
|
||||||
|
return this.monitor.pathName;
|
||||||
|
} else {
|
||||||
|
return this.monitor.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
|
||||||
|
// Always unfold if monitor is accessed directly
|
||||||
|
if (this.monitor.childrenIDs.includes(parseInt(this.$route.params.id))) {
|
||||||
|
this.isCollapsed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set collapsed value based on local storage
|
||||||
|
let storage = window.localStorage.getItem("monitorCollapsed");
|
||||||
|
if (storage === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let storageObject = JSON.parse(storage);
|
||||||
|
if (storageObject[`monitor_${this.monitor.id}`] == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCollapsed = storageObject[`monitor_${this.monitor.id}`];
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Changes the collapsed value of the current monitor and saves it to local storage
|
||||||
|
*/
|
||||||
|
changeCollapsed() {
|
||||||
|
this.isCollapsed = !this.isCollapsed;
|
||||||
|
|
||||||
|
// Save collapsed value into local storage
|
||||||
|
let storage = window.localStorage.getItem("monitorCollapsed");
|
||||||
|
let storageObject = {};
|
||||||
|
if (storage !== null) {
|
||||||
|
storageObject = JSON.parse(storage);
|
||||||
|
}
|
||||||
|
storageObject[`monitor_${this.monitor.id}`] = this.isCollapsed;
|
||||||
|
|
||||||
|
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get URL of monitor
|
||||||
|
* @param {number} id ID of monitor
|
||||||
|
* @returns {string} Relative URL of monitor
|
||||||
|
*/
|
||||||
|
monitorURL(id) {
|
||||||
|
return getMonitorRelativeURL(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.small-padding {
|
||||||
|
padding-left: 5px !important;
|
||||||
|
padding-right: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-padding {
|
||||||
|
padding-left: 8px !important;
|
||||||
|
padding-right: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .monitor-item {
|
||||||
|
// width: 100%;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 67px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated {
|
||||||
|
transition: all 0.2s $easing-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="MonitorSettingDialog" class="modal fade" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
{{ $t("Monitor Setting", [monitor.name]) }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="my-3 form-check">
|
||||||
|
<input id="show-clickable-link" v-model="monitor.isClickAble" class="form-check-input" type="checkbox" @click="toggleLink(monitor.group_index, monitor.monitor_index)" />
|
||||||
|
<label class="form-check-label" for="show-clickable-link">
|
||||||
|
{{ $t("Show Clickable Link") }}
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("Show Clickable Link Description") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-add-group me-2"
|
||||||
|
@click="$refs.badgeGeneratorDialog.show(monitor.id, monitor.name)"
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon="certificate" />
|
||||||
|
{{ $t("Open Badge Generator") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal">
|
||||||
|
{{ $t("Close") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BadgeGeneratorDialog ref="badgeGeneratorDialog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
import BadgeGeneratorDialog from "./BadgeGeneratorDialog.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
BadgeGeneratorDialog
|
||||||
|
},
|
||||||
|
props: {},
|
||||||
|
emits: [],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
monitor: {
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.MonitorSettingDialog = new Modal(this.$refs.MonitorSettingDialog);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Setting monitor
|
||||||
|
* @param {Object} group Data of monitor
|
||||||
|
* @param {Object} monitor Data of monitor
|
||||||
|
*/
|
||||||
|
show(group, monitor) {
|
||||||
|
this.monitor = {
|
||||||
|
id: monitor.element.id,
|
||||||
|
name: monitor.element.name,
|
||||||
|
monitor_index: monitor.index,
|
||||||
|
group_index: group.index,
|
||||||
|
isClickAble: this.showLink(monitor),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.MonitorSettingDialog.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the value of sendUrl
|
||||||
|
* @param {number} groupIndex Index of group monitor is member of
|
||||||
|
* @param {number} index Index of monitor within group
|
||||||
|
*/
|
||||||
|
toggleLink(groupIndex, index) {
|
||||||
|
this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl = !this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should a link to the monitor be shown?
|
||||||
|
* Attempts to guess if a link should be shown based upon if
|
||||||
|
* sendUrl is set and if the URL is default or not.
|
||||||
|
* @param {Object} monitor Monitor to check
|
||||||
|
* @param {boolean} [ignoreSendUrl=false] Should the presence of the sendUrl
|
||||||
|
* property be ignored. This will only work in edit mode.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
showLink(monitor, ignoreSendUrl = false) {
|
||||||
|
// We must check if there are any elements in monitorList to
|
||||||
|
// prevent undefined errors if it hasn't been loaded yet
|
||||||
|
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
|
||||||
|
return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword";
|
||||||
|
}
|
||||||
|
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
.modal-dialog .form-text, .modal-dialog p {
|
||||||
|
color: $dark-font-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,102 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="! (!plugin.installed && plugin.local)" class="plugin-item pt-4 pb-2">
|
|
||||||
<div class="info">
|
|
||||||
<h5>{{ plugin.fullName }}</h5>
|
|
||||||
<p class="description">
|
|
||||||
{{ plugin.description }}
|
|
||||||
</p>
|
|
||||||
<span class="version">{{ $t("Version") }}: {{ plugin.version }} <a v-if="plugin.repo" :href="plugin.repo" target="_blank">Repo</a></span>
|
|
||||||
</div>
|
|
||||||
<div class="buttons">
|
|
||||||
<button v-if="status === 'installing'" class="btn btn-primary" disabled>{{ $t("installing") }}</button>
|
|
||||||
<button v-else-if="status === 'uninstalling'" class="btn btn-danger" disabled>{{ $t("uninstalling") }}</button>
|
|
||||||
<button v-else-if="plugin.installed || status === 'installed'" class="btn btn-danger" @click="deleteConfirm">{{ $t("uninstall") }}</button>
|
|
||||||
<button v-else class="btn btn-primary" @click="install">{{ $t("install") }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="uninstall">
|
|
||||||
{{ $t("confirmUninstallPlugin") }}
|
|
||||||
</Confirm>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Confirm from "./Confirm.vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Confirm,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
plugin: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
status: "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
/**
|
|
||||||
* Show confirmation for deleting a tag
|
|
||||||
*/
|
|
||||||
deleteConfirm() {
|
|
||||||
this.$refs.confirmDelete.show();
|
|
||||||
},
|
|
||||||
|
|
||||||
install() {
|
|
||||||
this.status = "installing";
|
|
||||||
|
|
||||||
this.$root.getSocket().emit("installPlugin", this.plugin.repo, this.plugin.name, (res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
this.status = "";
|
|
||||||
// eslint-disable-next-line vue/no-mutating-props
|
|
||||||
this.plugin.installed = true;
|
|
||||||
} else {
|
|
||||||
this.$root.toastRes(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
uninstall() {
|
|
||||||
this.status = "uninstalling";
|
|
||||||
|
|
||||||
this.$root.getSocket().emit("uninstallPlugin", this.plugin.name, (res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
this.status = "";
|
|
||||||
// eslint-disable-next-line vue/no-mutating-props
|
|
||||||
this.plugin.installed = false;
|
|
||||||
} else {
|
|
||||||
this.$root.toastRes(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "../assets/vars.scss";
|
|
||||||
|
|
||||||
.plugin-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.info {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,57 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mt-3">{{ remotePluginListMsg }}</div>
|
|
||||||
<PluginItem v-for="plugin in remotePluginList" :key="plugin.id" :plugin="plugin" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import PluginItem from "../PluginItem.vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PluginItem
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
remotePluginList: [],
|
|
||||||
remotePluginListMsg: "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
pluginList() {
|
|
||||||
return this.$parent.$parent.$parent.pluginList;
|
|
||||||
},
|
|
||||||
settings() {
|
|
||||||
return this.$parent.$parent.$parent.settings;
|
|
||||||
},
|
|
||||||
saveSettings() {
|
|
||||||
return this.$parent.$parent.$parent.saveSettings;
|
|
||||||
},
|
|
||||||
settingsLoaded() {
|
|
||||||
return this.$parent.$parent.$parent.settingsLoaded;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
this.loadList();
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
loadList() {
|
|
||||||
this.remotePluginListMsg = this.$t("Loading") + "...";
|
|
||||||
|
|
||||||
this.$root.getSocket().emit("getPluginList", (res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
this.remotePluginList = res.pluginList;
|
|
||||||
this.remotePluginListMsg = "";
|
|
||||||
} else {
|
|
||||||
this.remotePluginListMsg = this.$t("loadingError") + " " + res.msg;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"Settings": "Paràmetres",
|
||||||
|
"Dashboard": "Tauler",
|
||||||
|
"Help": "Ajuda",
|
||||||
|
"New Update": "Nova actualització",
|
||||||
|
"Language": "Idioma",
|
||||||
|
"Appearance": "Aparença",
|
||||||
|
"Theme": "Tema",
|
||||||
|
"General": "General",
|
||||||
|
"Game": "Joc",
|
||||||
|
"Version": "Versió",
|
||||||
|
"Check Update On GitHub": "Comprovar actualitzacions a GitHub",
|
||||||
|
"List": "Llista",
|
||||||
|
"Home": "Inici",
|
||||||
|
"Add": "Afegir",
|
||||||
|
"Add New Monitor": "Afegir nou monitor",
|
||||||
|
"Quick Stats": "Estadístiques ràpides",
|
||||||
|
"Up": "Funcional",
|
||||||
|
"Down": "Caigut",
|
||||||
|
"Pending": "Pendent",
|
||||||
|
"Maintenance": "Manteniment",
|
||||||
|
"Unknown": "Desconegut",
|
||||||
|
"Cannot connect to the socket server": "No es pot connectar al servidor socket",
|
||||||
|
"Reconnecting...": "S'està tornant a connectar...",
|
||||||
|
"languageName": "Català",
|
||||||
|
"Primary Base URL": "URL Base Primària",
|
||||||
|
"statusMaintenance": "Manteniment"
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"languageName": "کوردی",
|
||||||
|
"Settings": "ڕێکخستنەکان",
|
||||||
|
"Help": "یارمەتی",
|
||||||
|
"New Update": "وەشانی نوێ",
|
||||||
|
"Language": "زمان",
|
||||||
|
"Appearance": "ڕووکار",
|
||||||
|
"Theme": "شێوەی ڕووکار",
|
||||||
|
"General": "گشتی",
|
||||||
|
"Game": "یاری",
|
||||||
|
"Version": "وەشان",
|
||||||
|
"Check Update On GitHub": "سەیری وەشانی نوێ بکە لە Github",
|
||||||
|
"List": "لیست",
|
||||||
|
"Add": "زیادکردن",
|
||||||
|
"Quick Stats": "ئاماری خێرا",
|
||||||
|
"Up": "سەروو",
|
||||||
|
"Down": "خواروو",
|
||||||
|
"Pending": "هەڵپەسێردراو",
|
||||||
|
"statusMaintenance": "چاکردنەوە",
|
||||||
|
"Maintenance": "چاکردنەوە",
|
||||||
|
"Unknown": "نەزانراو",
|
||||||
|
"Passive Monitor Type": "جۆری مۆنیتەری پاسیڤ",
|
||||||
|
"Specific Monitor Type": "جۆری مۆنیتەری تایبەت",
|
||||||
|
"markdownSupported": "ڕستەسازی مارکداون پشتگیری دەکرێت",
|
||||||
|
"pauseDashboardHome": "وچان",
|
||||||
|
"Pause": "وچان",
|
||||||
|
"Name": "ناو",
|
||||||
|
"Status": "دۆخ",
|
||||||
|
"Message": "پەیام",
|
||||||
|
"No important events": "هیچ ڕووداوێکی گرنگ نییە",
|
||||||
|
"Resume": "دەستپێکردنەوە",
|
||||||
|
"Edit": "بژارکردن",
|
||||||
|
"Delete": "سڕینەوە",
|
||||||
|
"Uptime": "کاتی کارکردن",
|
||||||
|
"Cert Exp.": "بەسەرچوونی بڕوانامەی SSL.",
|
||||||
|
"day": "ڕۆژ | ڕۆژەکان",
|
||||||
|
"-day": "-ڕۆژ",
|
||||||
|
"hour": "کاتژمێر",
|
||||||
|
"Dashboard": "داشبۆرد",
|
||||||
|
"Primary Base URL": "بەستەری بنچینەیی سەرەکی",
|
||||||
|
"Add New Monitor": "مۆنیتەرێکی نوێ زیاد بکە",
|
||||||
|
"General Monitor Type": "جۆری مۆنیتەری گشتی",
|
||||||
|
"DateTime": "رێکەوت",
|
||||||
|
"Current": "هەنووکە",
|
||||||
|
"Monitor": "مۆنیتەر | مۆنیتەرەکان"
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"Settings": "Axustes",
|
||||||
|
"Dashboard": "Panel",
|
||||||
|
"Help": "Axuda",
|
||||||
|
"General": "Xeral",
|
||||||
|
"List": "Lista",
|
||||||
|
"Home": "Casa",
|
||||||
|
"Add": "Engadir",
|
||||||
|
"Up": "Arriba",
|
||||||
|
"Pending": "Pendente",
|
||||||
|
"statusMaintenance": "Mantemento",
|
||||||
|
"Maintenance": "Mantemento",
|
||||||
|
"Unknown": "Descoñecido",
|
||||||
|
"Reconnecting...": "Reconectando...",
|
||||||
|
"pauseDashboardHome": "Pausa",
|
||||||
|
"Pause": "Pausa",
|
||||||
|
"Name": "Nome",
|
||||||
|
"Status": "Estado",
|
||||||
|
"DateTime": "DataHora",
|
||||||
|
"Message": "Mensaxe",
|
||||||
|
"languageName": "Galego",
|
||||||
|
"Down": "Abaixo"
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"Dashboard": "डैशबोर्ड",
|
||||||
|
"Help": "मदद",
|
||||||
|
"New Update": "नया अपडेट",
|
||||||
|
"Language": "भाषा",
|
||||||
|
"Appearance": "अपीयरेंस",
|
||||||
|
"Theme": "थीम",
|
||||||
|
"Game": "गेम",
|
||||||
|
"languageName": "हिंदी",
|
||||||
|
"Settings": "सेटिंग्स",
|
||||||
|
"General": "जनरल",
|
||||||
|
"List": "सूची",
|
||||||
|
"Add": "जोड़ें",
|
||||||
|
"Add New Monitor": "नया मॉनिटर जोड़ें",
|
||||||
|
"Pending": "लंबित",
|
||||||
|
"statusMaintenance": "रखरखाव",
|
||||||
|
"Maintenance": "रखरखाव",
|
||||||
|
"Unknown": "अज्ञात",
|
||||||
|
"Cannot connect to the socket server": "सॉकेट सर्वर से कनेक्ट नहीं हो सकता",
|
||||||
|
"pauseDashboardHome": "विराम",
|
||||||
|
"Resume": "फिर से शुरू करें",
|
||||||
|
"Delete": "हटाएं",
|
||||||
|
"Current": "मौजूदा",
|
||||||
|
"Up": "चालू",
|
||||||
|
"General Monitor Type": "सामान्य मॉनिटर प्रकार",
|
||||||
|
"Specific Monitor Type": "विशिष्ट मॉनिटर प्रकार",
|
||||||
|
"Pause": "विराम",
|
||||||
|
"Name": "नाम",
|
||||||
|
"Message": "संदेश",
|
||||||
|
"No important events": "कोई महत्वपूर्ण घटनाक्रम नहीं",
|
||||||
|
"Edit": "परिवर्तन",
|
||||||
|
"Ping": "पिंग",
|
||||||
|
"Monitor Type": "मॉनिटर प्रकार",
|
||||||
|
"Keyword": "कीवर्ड",
|
||||||
|
"Friendly Name": "दोस्ताना नाम",
|
||||||
|
"Version": "संस्करण",
|
||||||
|
"Home": "घर",
|
||||||
|
"Quick Stats": "शीघ्र आँकड़े",
|
||||||
|
"Reconnecting...": "पुनः कनेक्ट किया जा रहा है...",
|
||||||
|
"Down": "बंद",
|
||||||
|
"Passive Monitor Type": "निष्क्रिय मॉनिटर प्रकार",
|
||||||
|
"Status": "स्थिति"
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"Help": "Bantuan",
|
||||||
|
"New Update": "Kemaskini baharu",
|
||||||
|
"Appearance": "Penampilan",
|
||||||
|
"Theme": "Tema",
|
||||||
|
"General": "Umum",
|
||||||
|
"Game": "Permainan",
|
||||||
|
"Primary Base URL": "URL Pangkalan Utama",
|
||||||
|
"Version": "Versi",
|
||||||
|
"Add": "Menambah",
|
||||||
|
"Quick Stats": "Statistik ringkas",
|
||||||
|
"Up": "Dalam talian",
|
||||||
|
"Down": "Luar talian",
|
||||||
|
"Pending": "Belum selesai",
|
||||||
|
"statusMaintenance": "Membaiki",
|
||||||
|
"Maintenance": "Membaiki",
|
||||||
|
"Unknown": "Tidak ketahui",
|
||||||
|
"General Monitor Type": "Jenis monitor umum",
|
||||||
|
"Check Update On GitHub": "Semak kemas kini dalam GitHub",
|
||||||
|
"List": "Senarai",
|
||||||
|
"Specific Monitor Type": "Jenis monitor spesifik",
|
||||||
|
"markdownSupported": "Sintaks markdown disokong",
|
||||||
|
"languageName": "Bahasa inggeris",
|
||||||
|
"Dashboard": "Papan pemuka",
|
||||||
|
"Language": "Bahasa",
|
||||||
|
"Add New Monitor": "Tambah monitor baharu",
|
||||||
|
"Passive Monitor Type": "Jenis monitor pasif"
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue