mariadb-database.class.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. import moment from 'moment';
  2. import { MysqlError, Pool } from 'mysql';
  3. import defaults from '../../../common/defaults.module';
  4. import { ServiceConfig, validateParamType } from '../../../common/interfaces/service-config.interface';
  5. import { HttpCheckData, HttpCheckStatus, ServiceCheckData, ServiceCheckDataEntry } from '../../../common/lib/http-check-data.module';
  6. import { Logger } from '../../../common/util/logger.class';
  7. import { DatabaseException } from '../lib/database.exception';
  8. import { ServiceChangedStatus } from '../lib/service-changed-status.enum';
  9. import { ValidationException } from '../lib/validation-exception.class';
  10. import { DataProvider } from './data-provider.interface';
  11. import { HealthCheckDataProvider } from './health-check-data-provider.interface';
  12. // TODO: import { DBMigration } from './db-migration.class';
  13. import { MariaDBConnector } from './mariadb-connector.class';
  14. export class MariaDBDatabase implements DataProvider, HealthCheckDataProvider {
  15. private db: MariaDBConnector;
  16. constructor(pool: Pool) {
  17. this.db = new MariaDBConnector(pool);
  18. }
  19. public async open(migrate = false) {
  20. try {
  21. await this.db.connect();
  22. Logger.debug('Opened MariaDB Connection');
  23. if (migrate) {
  24. //TODO: RUN DB MIGRATIONS
  25. // const mig = new DBMigration(this.db);
  26. // await mig.update();
  27. }
  28. } catch (err) {
  29. Logger.error('[FATAL] Initializing Database failed:', err);
  30. Logger.error('[EXITING]');
  31. process.exit(1);
  32. }
  33. }
  34. public async getAllServerConfigs(): Promise<Server[]> {
  35. const res = await this.db.query(
  36. `SELECT
  37. \`Server\`.*,
  38. \`ServerConfig\`.\`Key\`,
  39. \`ServerConfig\`.\`Value\`
  40. FROM \`Server\`
  41. LEFT OUTER JOIN \`ServerConfig\` ON \`Server\`.\`ID\` = \`ServerConfig\`.\`ServerID\`
  42. ORDER BY \`Server\`.\`Title\`, \`ServerConfig\`.\`Key\``,
  43. []
  44. );
  45. return res.reduce((res: Server[], line: any, i) => {
  46. const serverID = line['ID'];
  47. let server: Server;
  48. if (i === 0 || res[res.length - 1].id !== serverID) {
  49. server = { id: serverID, title: line['Title'], fqdn: line['FQDN'], config: {} };
  50. res.push(server);
  51. } else {
  52. server = res[res.length - 1];
  53. }
  54. if (!!line['Key']) {
  55. server.config[line['Key']] = line['Value'];
  56. }
  57. return res;
  58. }, [] as Server[]);
  59. }
  60. public async insertServerData(serverID: number, data: ReducedData[]) {
  61. if (!data.length) return;
  62. await this.db.beginTransaction();
  63. try {
  64. for (const entry of data) {
  65. let entryID = 0;
  66. try {
  67. const result = await this.db.query(
  68. `INSERT INTO \`ServerDataEntry\`(\`ServerID\`, \`Timestamp\`) VALUES(?, ?);
  69. SELECT LAST_INSERT_ID() as 'ID';`,
  70. [serverID, entry.time]
  71. );
  72. if (!result || result.length < 2) throw new DatabaseException('Unexpected result during insertServerData');
  73. entryID = (result[1] as any[])[0]['ID'];
  74. } catch (err: any) {
  75. if (err.code === 'ER_DUP_ENTRY' && (err as MysqlError).sqlMessage?.includes('UQ_ServerDataEntry_1')) {
  76. Logger.warn(`[server:${serverID}] Skipping`, err.sqlMessage);
  77. continue;
  78. }
  79. }
  80. for (const type of Object.keys(entry).filter(t => !['time', 'hdd'].includes(t))) {
  81. for (const key of Object.keys((entry as any)[type])) {
  82. await this.db.query('INSERT INTO `ServerDataValue`(`EntryID`, `Type`, `Key`, `Value`) VALUES(?, ?, ?, ?);', [
  83. entryID,
  84. type,
  85. key,
  86. (entry as any)[type][key]
  87. ]);
  88. }
  89. }
  90. if (entry.hdd) {
  91. for (const mount of Object.keys(entry.hdd)) {
  92. for (const key of Object.keys(entry.hdd[mount])) {
  93. await this.db.query('INSERT INTO `ServerDataValue`(`EntryID`, `Type`, `Key`, `Value`) VALUES(?, ?, ?, ?);', [
  94. entryID,
  95. `hdd:${mount}`,
  96. key,
  97. (entry.hdd[mount] as any)[key]
  98. ]);
  99. }
  100. }
  101. }
  102. }
  103. await this.db.commit();
  104. } catch (err) {
  105. await this.db.rollback();
  106. throw err;
  107. }
  108. }
  109. public async getServerDataTypes(serverID: number) {
  110. const results: any[] = await this.db.query(
  111. `
  112. SELECT
  113. \`ServerDataValue\`.\`Type\`
  114. FROM \`ServerDataEntry\`
  115. JOIN \`ServerDataValue\` ON \`ServerDataEntry\`.\`ID\` = \`ServerDataValue\`.\`EntryID\`
  116. WHERE \`ServerDataEntry\`.\`ServerID\` = ?
  117. GROUP BY \`ServerDataValue\`.\`Type\`
  118. ORDER BY \`ServerDataValue\`.\`Type\`;
  119. `,
  120. [serverID]
  121. );
  122. return results.reduce((res: Array<ServerDataTypesConfig>, { Type: type }) => {
  123. if (!type.startsWith('hdd:')) {
  124. res.push({ type });
  125. } else {
  126. let hdd = res.find(c => c.type === 'hdd');
  127. if (!hdd) {
  128. hdd = { type: 'hdd', subtypes: [] };
  129. res.push(hdd);
  130. }
  131. hdd.subtypes?.push({ type: type.substring(4) });
  132. }
  133. return res;
  134. }, []) as Array<ServerDataTypesConfig>;
  135. }
  136. public async queryServerData(serverID: number, type: ServerDataType, from: Date, to: Date): Promise<ServerData[]> {
  137. const diffMs = moment(to).diff(moment(from));
  138. const sectionMs = Math.floor(diffMs / 100);
  139. const select_max = type !== 'cpu';
  140. const select_types = select_max ? [type, type, type] : [type, type];
  141. const result: any[] = await this.db.query(
  142. `
  143. SELECT
  144. CEIL(UNIX_TIMESTAMP(\`Timestamp\`) * 1000 / ?) * ? as 'Timegroup',
  145. AVG(\`VALUE_AVG\`.\`Value\`) as 'avg',
  146. MAX(\`VALUE_PEAK\`.\`Value\`) as 'peak'${
  147. select_max
  148. ? `,
  149. MAX(\`VALUE_MAX\`.\`Value\`) as 'max'`
  150. : ''
  151. }
  152. FROM \`ServerDataEntry\`
  153. JOIN \`ServerDataValue\` AS \`VALUE_AVG\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_AVG\`.\`EntryID\` AND \`VALUE_AVG\`.\`Type\` = ? AND \`VALUE_AVG\`.\`Key\` = 'avg'
  154. JOIN \`ServerDataValue\` AS \`VALUE_PEAK\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_PEAK\`.\`EntryID\` AND \`VALUE_PEAK\`.\`Type\` = ? AND \`VALUE_PEAK\`.\`Key\` = 'peak'
  155. ${
  156. select_max
  157. ? "JOIN `ServerDataValue` AS `VALUE_MAX` ON `ServerDataEntry`.`ID` = `VALUE_MAX`.`EntryID` AND `VALUE_MAX`.`Type` = ? AND `VALUE_MAX`.`Key` = 'max'"
  158. : ''
  159. }
  160. WHERE \`ServerDataEntry\`.\`ServerID\` = ?
  161. AND \`ServerDataEntry\`.\`Timestamp\` BETWEEN ? AND ?
  162. GROUP BY \`Timegroup\`
  163. ORDER BY \`Timegroup\`;
  164. `,
  165. [sectionMs, sectionMs, ...select_types, serverID, from, to]
  166. );
  167. return result.map(r => ({ time: new Date(r.Timegroup), avg: r.avg, peak: r.peak, max: r.max }));
  168. }
  169. public async reduceServerData(intervalSecs: number, to: Date, from?: Date) {
  170. const select_daterange = from ? [from, to] : [to];
  171. const result: any[] = await this.db.query(
  172. `
  173. SELECT
  174. MIN(\`ServerDataEntry\`.\`ID\`) AS 'minEntryID',
  175. MAX(\`ServerDataEntry\`.\`ID\`) AS 'maxEntryID',
  176. \`ServerDataEntry\`.\`ServerID\` AS 'serverID',
  177. FROM_UNIXTIME(CEIL(UNIX_TIMESTAMP(\`ServerDataEntry\`.\`Timestamp\`) / ?) * ?) AS 'timegroup',
  178. \`VALUE_AVG\`.\`Type\` AS 'type',
  179. MAX(\`VALUE_AVG\`.\`ID\`) AS 'maxAvgID',
  180. AVG(\`VALUE_AVG\`.\`Value\`) AS 'avg',
  181. MAX(\`VALUE_PEAK\`.\`ID\`) AS 'maxPeakID',
  182. MAX(\`VALUE_PEAK\`.\`Value\`) AS 'peak',
  183. MAX(\`VALUE_MAX\`.\`ID\`) AS 'maxMaxID',
  184. MAX(\`VALUE_MAX\`.\`Value\`) AS 'max'
  185. FROM \`ServerDataEntry\`
  186. LEFT OUTER JOIN \`ServerDataValue\` AS \`VALUE_AVG\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_AVG\`.\`EntryID\` AND \`VALUE_AVG\`.\`Key\` = 'avg'
  187. LEFT OUTER JOIN \`ServerDataValue\` AS \`VALUE_PEAK\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_PEAK\`.\`EntryID\` AND \`VALUE_AVG\`.\`Type\` = \`VALUE_PEAK\`.\`Type\` AND \`VALUE_PEAK\`.\`Key\` = 'peak'
  188. LEFT OUTER JOIN \`ServerDataValue\` AS \`VALUE_MAX\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_MAX\`.\`EntryID\` AND \`VALUE_PEAK\`.\`Type\` = \`VALUE_MAX\`.\`Type\` AND \`VALUE_MAX\`.\`Key\` = 'max'
  189. WHERE ${from ? '`ServerDataEntry`.`Timestamp` BETWEEN ? AND ?' : '`ServerDataEntry`.`Timestamp` < ?'}
  190. GROUP BY \`serverID\`, \`timegroup\`, \`type\`
  191. ORDER BY \`serverID\`, \`timegroup\`, \`maxAvgID\`;`,
  192. [intervalSecs, intervalSecs, ...select_daterange]
  193. );
  194. let lastRow: any | undefined = undefined;
  195. let queries: string[] = [];
  196. let params: any[] = [];
  197. const execTransaction = async (row: any) => {
  198. await this.db.beginTransaction();
  199. try {
  200. Logger.debug(
  201. '[MARIADB] Reducing Server Data:',
  202. JSON.stringify({
  203. server: row.serverID,
  204. time: row.timegroup
  205. })
  206. );
  207. await this.db.query(queries.join('\n'), params);
  208. await this.db.commit();
  209. } catch (error) {
  210. Logger.error('[MARIADB] Reducing Server Data failed:', new String(error).toString());
  211. await this.db.rollback();
  212. }
  213. };
  214. for (const row of result) {
  215. const { minEntryID, maxEntryID, serverID, timegroup } = row;
  216. if (lastRow?.maxEntryID !== maxEntryID) {
  217. if (queries.length) await execTransaction(row);
  218. queries = [];
  219. params = [];
  220. }
  221. if (!queries.length) {
  222. // PARENT ENTRIES
  223. queries.push(
  224. 'DELETE FROM `ServerDataEntry` WHERE `ID` != ? AND `ServerID` = ? AND `Timestamp` = ?;', // Avoid ER_DUP_ENTRY on UQ(ServerID, Timestamp):
  225. 'UPDATE `ServerDataEntry` SET `Timestamp` = ? WHERE ID = ?;'
  226. );
  227. params.push(maxEntryID, serverID, timegroup, timegroup, maxEntryID);
  228. if (maxEntryID - 1 - minEntryID >= 0) {
  229. queries.push('DELETE FROM `ServerDataEntry` WHERE ServerID = ? AND ID >= ? AND ID <= ?;');
  230. params.push(serverID, minEntryID, maxEntryID - 1);
  231. }
  232. }
  233. lastRow = row;
  234. }
  235. if (queries.length) {
  236. await execTransaction(lastRow);
  237. }
  238. }
  239. public async queryServerStats(serverID: number, type: ServerDataType, from: Date, to: Date): Promise<ReducedValuesPerc> {
  240. const select_max = type !== 'cpu';
  241. const select_types = select_max ? [type, type, type] : [type, type];
  242. const result: any[] = await this.db.query(
  243. `
  244. SELECT
  245. AVG(\`VALUE_AVG\`.\`Value\`) as 'avg',
  246. AVG(\`VALUE_PEAK\`.\`Value\`) as 'peak'${
  247. select_max
  248. ? `,
  249. MAX(\`VALUE_MAX\`.\`Value\`) as 'max'`
  250. : ''
  251. }
  252. FROM \`ServerDataEntry\`
  253. JOIN \`ServerDataValue\` AS \`VALUE_AVG\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_AVG\`.\`EntryID\` AND \`VALUE_AVG\`.\`Type\` = ? AND \`VALUE_AVG\`.\`Key\` = 'avg'
  254. JOIN \`ServerDataValue\` AS \`VALUE_PEAK\` ON \`ServerDataEntry\`.\`ID\` = \`VALUE_PEAK\`.\`EntryID\` AND \`VALUE_PEAK\`.\`Type\` = ? AND \`VALUE_PEAK\`.\`Key\` = 'peak'
  255. ${
  256. select_max
  257. ? "JOIN `ServerDataValue` AS `VALUE_MAX` ON `ServerDataEntry`.`ID` = `VALUE_MAX`.`EntryID` AND `VALUE_MAX`.`Type` = ? AND `VALUE_MAX`.`Key` = 'max'"
  258. : ''
  259. }
  260. WHERE \`ServerDataEntry\`.\`ServerID\` = ?
  261. AND \`ServerDataEntry\`.\`Timestamp\` BETWEEN ? AND ?;
  262. `,
  263. [...select_types, serverID, from, to]
  264. );
  265. const row = result[0];
  266. if (Object.keys(row).includes('max')) {
  267. return {
  268. avg: ((row['avg'] as number) / (row['max'] as number)) * 100,
  269. peak: ((row['peak'] as number) / (row['max'] as number)) * 100
  270. };
  271. } else {
  272. return {
  273. avg: row['avg'] as number,
  274. peak: row['peak'] as number
  275. };
  276. }
  277. }
  278. private async getHealthCheckConfigs(serverID?: number, type = 'http') {
  279. const res: any[] = await this.db.query(
  280. `SELECT
  281. \`HealthCheckConfig\`.*,
  282. \`HealthCheckParams\`.\`Type\` as '_ParamType',
  283. \`HealthCheckParams\`.\`Key\` as '_ParamKey',
  284. \`HealthCheckParams\`.\`Value\` as '_ParamValue'
  285. FROM \`HealthCheckConfig\`
  286. LEFT OUTER JOIN \`HealthCheckParams\` ON \`HealthCheckConfig\`.\`ID\` = \`HealthCheckParams\`.\`ConfigID\`
  287. WHERE \`HealthCheckConfig\`.\`Type\` = ?
  288. ${!!serverID ? 'AND `HealthCheckConfig`.`ServerID` = ?' : ''}
  289. ORDER BY \`HealthCheckConfig\`.\`Title\`, _ParamType, _ParamKey`,
  290. [type, serverID]
  291. );
  292. return this.configFromResultRows(res);
  293. }
  294. public async getHttpCheckConfigs(serverID?: number) {
  295. return (await this.getHealthCheckConfigs(serverID)).map(this.httpCheckConfigFrom);
  296. }
  297. private async getHealthCheckConfigByID(serverID: number, configID: number) {
  298. if (!serverID && !configID) return null;
  299. const res: any[] = await this.db.query(
  300. `SELECT
  301. \`HealthCheckConfig\`.*,
  302. \`HealthCheckParams\`.\`Type\` as '_ParamType',
  303. \`HealthCheckParams\`.\`Key\` as '_ParamKey',
  304. \`HealthCheckParams\`.\`Value\` as '_ParamValue'
  305. FROM \`HealthCheckConfig\`
  306. LEFT OUTER JOIN \`HealthCheckParams\` ON \`HealthCheckConfig\`.\`ID\` = \`HealthCheckParams\`.\`ConfigID\`
  307. WHERE \`HealthCheckConfig\`.\`ID\` = ?
  308. AND \`HealthCheckConfig\`.\`ServerID\` = ?
  309. ORDER BY \`HealthCheckConfig\`.\`Title\`, _ParamType, _ParamKey`,
  310. [configID, serverID]
  311. );
  312. if (!res.length) return null;
  313. const configs = this.configFromResultRows(res);
  314. return configs[0];
  315. }
  316. public async getHttpCheckConfigByID(serverID: number, configID: number) {
  317. return this.httpCheckConfigFrom(await this.getHealthCheckConfigByID(serverID, configID));
  318. }
  319. public async saveHttpCheckConfig(serverID: number, conf: HttpCheckConfig) {
  320. const validationErrors = this.validateHttpCheckConfig(conf);
  321. if (validationErrors) throw new ValidationException('Validation of HttpCheckConfig object failed', validationErrors);
  322. conf.serverId = serverID;
  323. let status = ServiceChangedStatus.None;
  324. const oldConf = await this.getHttpCheckConfigByID(serverID, conf.id);
  325. await this.db.beginTransaction();
  326. try {
  327. if (oldConf) {
  328. // UPDATE
  329. Logger.debug('Updating HealthCheckConfig', conf.title, `(${oldConf.id})`);
  330. if (oldConf.title !== conf.title) {
  331. await this.db.query('UPDATE `HealthCheckConfig` SET `Title` = ? WHERE `ID` = ?', [conf.title, oldConf.id]);
  332. }
  333. let updValues: any[][] = [];
  334. if (oldConf.url !== conf.url) updValues.push([conf.url, conf.id, 'url']);
  335. if (oldConf.interval !== conf.interval) {
  336. updValues.push([conf.interval, conf.id, 'interval']);
  337. status = ServiceChangedStatus.Rescheduled;
  338. }
  339. if (oldConf.timeout !== conf.timeout) updValues.push([conf.timeout ?? defaults.serviceChecks.httpTimeout, conf.id, 'timeout']);
  340. if (oldConf.active !== conf.active) {
  341. updValues.push([conf.active ?? defaults.serviceChecks.active ? 1 : 0, conf.id, 'active']);
  342. status = conf.active ?? defaults.serviceChecks.active ? ServiceChangedStatus.Activated : ServiceChangedStatus.Deactivated;
  343. }
  344. if (oldConf.notify !== conf.notify) updValues.push([conf.notify ?? defaults.serviceChecks.notify ? 1 : 0, conf.id, 'notify']);
  345. if (oldConf.notifyThreshold !== conf.notifyThreshold)
  346. updValues.push([conf.notifyThreshold ?? defaults.serviceChecks.notifyThreshold, conf.id, 'notifyThreshold']);
  347. if (updValues.length) {
  348. for (const data of updValues) {
  349. await this.db.query('UPDATE `HealthCheckParams` SET `Value` = ? WHERE `ConfigID` = ? AND `Key` = ?;', data);
  350. }
  351. }
  352. const res = await this.db.query('SELECT * FROM `HealthCheckParams` WHERE `ConfigID` = ? and `Key` = "check";', [conf.id]);
  353. updValues = [];
  354. const delIDs: number[] = [];
  355. res.forEach((row: any, i) => {
  356. if (i < conf.checks.length) {
  357. updValues.push([JSON.stringify(conf.checks[i]), row['ID']]);
  358. } else {
  359. delIDs.push(row['ID']);
  360. }
  361. });
  362. if (delIDs.length) {
  363. const delSql = `DELETE FROM \`HealthCheckParams\` WHERE \`ID\` IN (${delIDs.map(() => '?').join(',')});`;
  364. await this.db.query(delSql, delIDs);
  365. }
  366. if (updValues.length) {
  367. for (const data of updValues) {
  368. await this.db.query('UPDATE `HealthCheckParams` SET `Value` = ? WHERE `ID` = ?;', data);
  369. }
  370. }
  371. const insValues = conf.checks.filter((c, i) => i > res.length - 1).map(c => [conf.id, 'regexp', 'check', JSON.stringify(c)]);
  372. if (insValues.length) {
  373. for (const data of insValues) {
  374. await this.db.query('INSERT INTO `HealthCheckParams`(`ConfigID`, `Type`, `Key`, `Value`) VALUES(?, ?, ?, ?);', data);
  375. }
  376. }
  377. } else {
  378. // INSERT
  379. Logger.debug('Inserting new HealthCheckConfig', conf.title);
  380. const res = await this.db.query(
  381. `INSERT INTO \`HealthCheckConfig\`(\`ServerID\`, \`Type\`, \`Title\`) VALUES(?, ?, ?);
  382. SELECT LAST_INSERT_ID() as ID;`,
  383. [serverID, 'http', conf.title]
  384. );
  385. if (!res || res.length < 2) throw new DatabaseException('Unexpected result during saveHttpCheckConfig');
  386. conf.id = (res[1] as any[])[0]['ID'];
  387. if (conf.active ?? defaults.serviceChecks.active) {
  388. status = ServiceChangedStatus.Created;
  389. }
  390. const insCheckValues = conf.checks.map(c => [conf.id, 'regexp', 'check', c]);
  391. await this.db.query(
  392. `INSERT INTO \`HealthCheckParams\`(\`ConfigID\`, \`Type\`, \`Key\`, \`Value\`) VALUES
  393. (?, ?, ?, ?),
  394. (?, ?, ?, ?),
  395. (?, ?, ?, ?),
  396. (?, ?, ?, ?),
  397. (?, ?, ?, ?),
  398. (?, ?, ?, ?)${conf.checks.length ? `,${insCheckValues.map(() => '(?, ?, ?, ?)').join(',')}` : ''}`,
  399. [
  400. ...[conf.id, 'text', 'url', conf.url],
  401. ...[conf.id, 'boolean', 'active', conf.active ?? defaults.serviceChecks.active ? 1 : 0],
  402. ...[conf.id, 'number', 'interval', conf.interval],
  403. ...[conf.id, 'number', 'timeout', conf.timeout ?? defaults.serviceChecks.httpTimeout],
  404. ...[conf.id, 'boolean', 'notify', conf.notify ?? defaults.serviceChecks.notify],
  405. ...[conf.id, 'number', 'notifyThreshold', conf.notifyThreshold ?? defaults.serviceChecks.notifyThreshold],
  406. ...conf.checks.reduce((ret, check) => [...ret, conf.id, 'regexp', 'check', JSON.stringify(check)], [] as any[])
  407. ]
  408. );
  409. }
  410. await this.db.commit();
  411. return { status, result: conf };
  412. } catch (err) {
  413. await this.db.rollback();
  414. throw err;
  415. }
  416. }
  417. async deleteHealthCheckConfig(serverID: number, confID: number) {
  418. const conf = await this.getHealthCheckConfigByID(serverID, confID);
  419. if (!conf) return false;
  420. await this.db.query('DELETE FROM `HealthCheckConfig` WHERE `ID` = ?;', [confID]);
  421. return true;
  422. }
  423. async insertHealthCheckData(confID: number, time: Date, status: HttpCheckStatus, message: string) {
  424. const res = await this.db.query(
  425. `INSERT INTO \`HealthCheckDataEntry\`(\`ConfigID\`, \`Timestamp\`, \`Status\`, \`Message\`) VALUES(?, ?, ?, ?);
  426. SELECT LAST_INSERT_ID() as ID;`,
  427. [confID, time, status, message]
  428. );
  429. if (!res || res.length < 2) throw new DatabaseException('Unexpected result during insertHealthCheckData');
  430. const id = (res[1] as any[])[0]['ID'];
  431. return {
  432. id,
  433. configId: confID,
  434. time,
  435. status,
  436. message
  437. } as HttpCheckData;
  438. }
  439. async queryServiceCheckData(serverID: number, confID: number, from: Date, to: Date) {
  440. const result = await this.db.query(
  441. `
  442. SELECT \`DataEntryChanges\`.*
  443. FROM \`HealthCheckConfig\`
  444. JOIN (
  445. SELECT * FROM (
  446. SELECT
  447. *
  448. FROM \`HealthCheckDataEntry\`
  449. WHERE \`ConfigID\` = ?
  450. AND \`Timestamp\` BETWEEN ? AND ?
  451. ORDER BY \`ID\`
  452. LIMIT 0, 1
  453. ) AS \`FIRST_STATE\`
  454. UNION
  455. SELECT
  456. \`ID\`,
  457. \`ConfigID\`,
  458. \`Timestamp\`,
  459. \`Status\`,
  460. \`Message\`
  461. FROM
  462. (
  463. SELECT
  464. \`HealthCheckDataEntry\`.*,
  465. LAG(\`Status\`) OVER (ORDER BY \`ConfigID\`, \`Timestamp\`) AS previous_state,
  466. LAG(\`Message\`) OVER (ORDER BY \`ConfigID\`, \`Timestamp\`) AS previous_msg
  467. FROM \`HealthCheckDataEntry\`
  468. WHERE \`ConfigID\` = ?
  469. ) AS HCDE2
  470. WHERE \`Status\` != previous_state
  471. AND \`Message\` != previous_msg
  472. UNION
  473. SELECT * FROM (
  474. SELECT
  475. *
  476. FROM \`HealthCheckDataEntry\`
  477. WHERE \`ConfigID\` = ?
  478. AND \`Timestamp\` BETWEEN ? AND ?
  479. ORDER BY ID DESC
  480. LIMIT 0, 1
  481. ) AS \`LAST_STATE\`
  482. ORDER BY \`ConfigID\`, \`Timestamp\`
  483. ) AS \`DataEntryChanges\` ON \`DataEntryChanges\`.\`ConfigID\` = \`HealthCheckConfig\`.\`ID\`
  484. WHERE \`HealthCheckConfig\`.\`ServerID\` = ?
  485. AND \`DataEntryChanges\`.\`Timestamp\` BETWEEN ? AND ?
  486. ORDER BY \`Timestamp\`, \`ID\`;`,
  487. [confID, from, to, confID, confID, from, to, serverID, from, to]
  488. );
  489. const mapByTimestamp = this.mapServiceCheckDataByTimestamp(result);
  490. const arr: ServiceCheckData[] = [];
  491. for (const entry of mapByTimestamp.entries()) {
  492. arr.push({
  493. time: new Date(entry[0]),
  494. data: entry[1]
  495. });
  496. }
  497. return arr;
  498. }
  499. public async queryServiceCheckLogs(serverID: number, confID: number, from: Date, to: Date) {
  500. const result = await this.db.query(
  501. `
  502. SELECT \`HealthCheckDataEntry\`.*
  503. FROM \`HealthCheckConfig\`
  504. JOIN \`HealthCheckDataEntry\` ON \`HealthCheckDataEntry\`.\`ConfigID\` = \`HealthCheckConfig\`.\`ID\`
  505. WHERE \`HealthCheckConfig\`.\`ID\` = ?
  506. AND \`HealthCheckConfig\`.\`ServerID\` = ?
  507. AND \`HealthCheckDataEntry\`.\`Timestamp\` BETWEEN ? AND ?
  508. ORDER BY \`Timestamp\`, \`ConfigID\`;`,
  509. [confID, serverID, from, to]
  510. );
  511. const mapByTimestamp = this.mapServiceCheckDataByTimestamp(result);
  512. const arr: ServiceCheckData[] = [];
  513. for (const entry of mapByTimestamp.entries()) {
  514. arr.push({
  515. time: new Date(entry[0]),
  516. data: entry[1]
  517. });
  518. }
  519. return arr;
  520. }
  521. public async getLastErrors(confID: number, threshold: number) {
  522. const result = await this.db.query(
  523. `SELECT HCDE1.*
  524. FROM \`HealthCheckDataEntry\` as HCDE1
  525. INNER JOIN (
  526. SELECT \`Timestamp\`
  527. FROM \`HealthCheckDataEntry\`
  528. WHERE \`ConfigID\` = ?
  529. GROUP BY \`Timestamp\`
  530. ORDER BY \`Timestamp\` DESC
  531. LIMIT 0, ?
  532. ) AS HCDE2 ON HCDE1.\`Timestamp\` = HCDE2.\`Timestamp\`
  533. WHERE \`ConfigID\` = ?
  534. ORDER BY HCDE1.\`Timestamp\` DESC, HCDE1.\`ID\` DESC`,
  535. [confID, threshold, confID]
  536. );
  537. const mapByTimestamp = this.mapServiceCheckDataByTimestamp(result);
  538. const errors: ServiceCheckData[] = [];
  539. for (const entry of mapByTimestamp.entries()) {
  540. const time = entry[0];
  541. const data = entry[1];
  542. const errorData = data.filter(d => d.status !== HttpCheckStatus.OK);
  543. if (!errorData.length) break;
  544. errors.push({
  545. time: new Date(time),
  546. data: errorData
  547. });
  548. }
  549. return errors;
  550. }
  551. private mapServiceCheckDataByTimestamp(rows: any[]) {
  552. return rows.reduce((res: Map<number, ServiceCheckDataEntry[]>, row) => {
  553. const time: number = row['Timestamp'];
  554. if (!res.has(time)) res.set(time, []);
  555. res.get(time)?.push({
  556. status: row['Status'] as number,
  557. message: row['Message']
  558. });
  559. return res;
  560. }, new Map()) as Map<number, ServiceCheckDataEntry[]>;
  561. }
  562. private configFromResultRows(rows: any[]) {
  563. return rows.reduce((res: ServiceConfig[], line, i) => {
  564. const configID = line['ID'];
  565. let config: ServiceConfig;
  566. if (i === 0 || res[res.length - 1].id !== configID) {
  567. config = {
  568. id: configID,
  569. title: line['Title'],
  570. type: line['Type'],
  571. serverId: line['ServerID'],
  572. params: []
  573. };
  574. res.push(config);
  575. } else {
  576. config = res[res.length - 1];
  577. }
  578. if (!!line['_ParamKey']) {
  579. const type = validateParamType(line['_ParamType']);
  580. const key = line['_ParamKey'];
  581. if (key === 'check') {
  582. let checkParam = config.params.find(c => c.key === 'check');
  583. if (!checkParam) {
  584. config.params.push(
  585. (checkParam = {
  586. key: 'check',
  587. type: 'regexp',
  588. value: []
  589. })
  590. );
  591. }
  592. (checkParam.value as string[]).push(line['_ParamValue']);
  593. } else {
  594. config.params.push({
  595. type,
  596. key,
  597. value: type === 'number' ? Number(line['_ParamValue']) : type === 'boolean' ? Boolean(Number(line['_ParamValue'])) : line['_ParamValue']
  598. });
  599. }
  600. }
  601. return res;
  602. }, [] as ServiceConfig[]);
  603. }
  604. private httpCheckConfigFrom(hcConf: ServiceConfig | null): HttpCheckConfig | null {
  605. if (!hcConf) return null;
  606. const params = {
  607. url: hcConf.params?.find(p => p.key === 'url')?.value as string,
  608. active: (hcConf.params?.find(p => p.key === 'active')?.value as boolean) ?? defaults.serviceChecks.active,
  609. interval: hcConf.params?.find(p => p.key === 'interval')?.value as number,
  610. timeout: (hcConf.params?.find(p => p.key === 'timeout')?.value as number) ?? defaults.serviceChecks.httpTimeout,
  611. notify: (hcConf.params?.find(p => p.key === 'notify')?.value as boolean) ?? defaults.serviceChecks.notify,
  612. notifyThreshold: (hcConf.params?.find(p => p.key === 'notifyThreshold')?.value as number) ?? defaults.serviceChecks.notifyThreshold,
  613. checks: hcConf.params?.reduce(
  614. (res, p) => (p.key === 'check' && Array.isArray(p.value) ? [...res, ...p.value.map(c => JSON.parse(c))] : res),
  615. [] as string[]
  616. )
  617. };
  618. return {
  619. id: hcConf.id,
  620. title: hcConf.title,
  621. type: hcConf.type,
  622. serverId: hcConf.serverId,
  623. ...params
  624. };
  625. }
  626. private validateHttpCheckConfig(conf: Partial<HttpCheckConfig>): { [key: string]: string } | null {
  627. const errors = {} as any;
  628. if (!conf) return { null: 'Object was null or undefined' };
  629. if (!conf.title?.trim().length) errors['required|title'] = `Field 'title' is required.`;
  630. if (!conf.url?.trim().length) errors['required|url'] = `Field 'url' is required.`;
  631. if ((!conf.interval && conf.interval !== 0) || Number.isNaN(Number(conf.interval))) errors['required|interval'] = `Field 'interval' is required.`;
  632. if (!conf.checks || !Array.isArray(conf.checks))
  633. errors['required|checks'] = `Field 'checks' is required and must be an array of check expressions.`;
  634. return Object.keys(errors).length ? errors : null;
  635. }
  636. public cleanupData() {
  637. process.nextTick(async () => {
  638. await this.cleanupHealthCheckData();
  639. await this.cleanupServerData();
  640. });
  641. }
  642. private async cleanupHealthCheckData() {
  643. const nowMinus3M = moment().subtract(3, 'months').toDate();
  644. await this.db.query('DELETE FROM `HealthCheckDataEntry` WHERE Timestamp < ?;', [nowMinus3M]);
  645. }
  646. private async cleanupServerData() {
  647. const nowMinus3M = moment().subtract(3, 'months').toDate();
  648. const nowMinus1M = moment().subtract(1, 'month').toDate();
  649. const nowMinus2W = moment().subtract(2, 'weeks').toDate();
  650. // Reduce Data older than 3Mo to reduced data points like if requested in a 3Mo UI chart
  651. let intervalSeconds = Math.floor(((365.2422 / 12) * 3 * 24 * 60 * 60) / 100);
  652. await this.reduceServerData(intervalSeconds, nowMinus3M);
  653. // Reduce Data older than 1Mo to reduced data points like if requested in a 1Mo UI chart
  654. intervalSeconds = Math.floor(((365.2422 / 12) * 1 * 24 * 60 * 60) / 100);
  655. await this.reduceServerData(intervalSeconds, nowMinus1M, nowMinus3M);
  656. // Reduce Data older than 2We to reduced data points like if requested in a 2We UI chart
  657. intervalSeconds = Math.floor((14 * 24 * 60 * 60) / 100);
  658. await this.reduceServerData(intervalSeconds, nowMinus2W, nowMinus1M);
  659. }
  660. public async close(): Promise<void> {
  661. await this.db.close();
  662. }
  663. }