[READ-ONLY] Mirror of https://github.com/FoxxMD/komodo-utilities. Small utilities to enhance Komodo
gotify komodo
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement unresolved alert timeout

FoxxMD (Jan 13, 2025, 6:36 PM UTC) ffb67d05 35c7d0c3

+226 -67
+17 -13
notifiers/apprise/program.ts
··· 6 6 import { isPortReachable, joinedUrl } from "../common/networkUtils.ts"; 7 7 import { titleAndSubtitle } from "../common/notifierBuilder.ts"; 8 8 import { valToBoolean } from "../../common/utils.ts"; 9 + import { setTimeout } from "node:timers"; 10 + import { createNotifierPipe } from "../common/notifierUtils.ts"; 11 + 9 12 10 13 interface AppriseOptions { 11 14 endpoint: URLData ··· 201 204 } 202 205 }; 203 206 207 + const doAlert = async (data: CommonAlert, alert: Types.Alert) => { 208 + try { 209 + await pushAlert(data, alert.level); 210 + } catch (e) { 211 + console.debug("Komodo Alert Payload:", alert); 212 + console.error( 213 + new Error("Failed to push Alert to Apprise", { cause: e }), 214 + ); 215 + } 216 + } 217 + 218 + const notifierPipe = createNotifierPipe(commonOpts); 219 + 204 220 const server = Deno.serve({ port: 7000 }, async (req) => { 205 221 const alert: Types.Alert = await req.json(); 206 222 console.log(`Recieved data from ${req.headers.get("host")}...`); ··· 215 231 return new Response(); 216 232 } 217 233 218 - if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { 219 - console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); 220 - return new Response(); 221 - } 222 - 223 - try { 224 - await pushAlert(data, alert.level); 225 - } catch (e) { 226 - console.debug("Komodo Alert Payload:", alert); 227 - console.error( 228 - new Error("Failed to push Alert to Apprise", { cause: e }), 229 - ); 230 - } 234 + await notifierPipe(alert, () => doAlert(data, alert)) 231 235 232 236 return new Response(); 233 237 });
+9
notifiers/common/alertUtils.ts
··· 1 + import { Types } from "npm:komodo_client"; 2 + 3 + export const resolveableAlertIdentifier = (alert: Types.Alert): string => { 4 + return `${alert.target.id}-${alert.data.type}`; 5 + } 6 + 7 + export const isAlertType = (types: string[], alert: Types.Alert): boolean => { 8 + return types.map(x => x.toLocaleLowerCase()).includes(alert.data.type.toLocaleLowerCase()); 9 + }
+11
notifiers/common/dataUtils.ts
··· 1 + export const intersect = (a: Array<any>, b: Array<any>) => { 2 + const setA = new Set(a); 3 + const setB = new Set(b); 4 + const intersection = new Set([...setA].filter(x => setB.has(x))); 5 + return Array.from(intersection); 6 + } 7 + 8 + 9 + export function sleep(ms: any) { 10 + return new Promise(resolve => setTimeout(resolve, ms)); 11 + }
+69
notifiers/common/notifierUtils.ts
··· 1 + import { setTimeout, clearTimeout } from "node:timers"; 2 + import { alertResolvedAllowed, CommonOptions } from "./options.ts"; 3 + import { Types } from "npm:komodo_client"; 4 + import { isAlertType, resolveableAlertIdentifier } from "./alertUtils.ts"; 5 + 6 + const alertFriendly = (alert: Types.Alert): string => `Alert ${alert._id} for ${alert.target.id}-${alert.data.type}`; 7 + 8 + export const createNotifierPipe = (opts: CommonOptions) => { 9 + 10 + const { 11 + resolverTimeout, 12 + resolverTypes = [], 13 + allowedResolveTypes 14 + } = opts; 15 + 16 + const resolverTimeouts = new Map<string,NodeJS.Timeout>(); 17 + 18 + return async (alert: Types.Alert, pushFunc: () => Promise<void>) => { 19 + 20 + if(!alertResolvedAllowed(allowedResolveTypes, alert.resolved)) { 21 + console.debug(`Not pushing ${alertFriendly(alert)} becuase it is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${allowedResolveTypes}'`); 22 + return; 23 + } 24 + 25 + if(resolverTimeout === undefined || (resolverTypes.length > 0 && !isAlertType(resolverTypes, alert))) { 26 + console.debug(`Pushing ${alertFriendly(alert)}`); 27 + return await pushFunc(); 28 + } 29 + 30 + const id = resolveableAlertIdentifier(alert); 31 + 32 + if(alert.resolved) { 33 + // if we had a waiting unresolved then the assumption is we *do not* want to notify of the resolved status 34 + // since it was transient 35 + if(resolverTimeouts.has(id)) { 36 + console.debug(`Not pushing ${alertFriendly(alert)} because it is RESOVLED and had an UNRESOLVED notification that was wait waiting to be pushed (and has now been canceled).`) 37 + clearTimeout(resolverTimeouts.get(id)); 38 + resolverTimeouts.delete(id); 39 + return; 40 + } else { 41 + // if there was no unresolved then the unresolved was either already sent or there was never an unresolved to begin with 42 + // in either case, notify of resolved 43 + console.debug(`Pushing ${alertFriendly(alert)}`); 44 + return await pushFunc(); 45 + } 46 + } else { 47 + // at this point we know the alert is unresolved 48 + // and alert was either in resolverTypes or resolverTypes was empty (wait on all unresolved) 49 + 50 + if(resolverTimeouts.has(id)) { 51 + // if alert was produced again, before previous timeout has ended, clear the previous 52 + console.debug(`${alertFriendly(alert)} -- replacing previously waiting notification of same type (reset timeout)`); 53 + clearTimeout(resolverTimeouts.get(id)); 54 + resolverTimeouts.delete(id); 55 + } 56 + 57 + const timeout = setTimeout(async () => { 58 + console.debug(`${alertFriendly(alert)} -- timeout of ${resolverTimeout} lapsed, pushing`); 59 + await pushFunc(); 60 + resolverTimeouts.delete(id); 61 + }, resolverTimeout); 62 + 63 + console.debug(`${alertFriendly(alert)} -- set timeout for ${resolverTimeout} ms`); 64 + resolverTimeouts.set(id, timeout); 65 + } 66 + 67 + return; 68 + }; 69 + }
+49 -1
notifiers/common/options.ts
··· 1 + import { Types } from "npm:komodo_client"; 1 2 import { valToBoolean } from "../../common/utils.ts"; 2 3 3 4 export type ResolvedType = "resolved" | "unresolved"; 5 + export type AlertTypes = Types.Alert['data']['type']; 4 6 5 7 export interface CommonOptions { 6 8 levelInTitle?: boolean; 7 9 resolvedIndicator?: boolean; 8 10 allowedResolveTypes?: ResolvedType[]; 11 + resolverTimeout?: number 12 + resolverTypes?: AlertTypes[] 9 13 } 10 14 11 15 export const asResolvedType = (val: string): val is ResolvedType => { ··· 17 21 levelInTitle: lit, 18 22 resolvedIndicator: ri, 19 23 allowedResolveTypes: fr, 24 + resolverTypes: rt, 25 + resolverTimeout: rtime, 20 26 } = data; 21 27 22 28 let levelInTitle = lit; ··· 56 62 } 57 63 } 58 64 } 65 + 66 + let resolverTypes = rt; 67 + if (resolverTypes === undefined) { 68 + const types = Deno.env.get("UNRESOLVED_TIMEOUT_TYPES"); 69 + if(types === undefined || types.trim() === '') { 70 + resolverTypes = undefined; 71 + } else { 72 + try { 73 + resolverTypes = parseAlertTypesString(types); 74 + } catch (e) { 75 + throw new Error(`Could not parse 'UNRESOLVED_TIMEOUT_TYPES' ENV`, {cause: e}); 76 + } 77 + } 78 + } 79 + 80 + let resolverTimeout = rtime; 81 + if (resolverTimeout === undefined) { 82 + const timeVal = Deno.env.get("UNRESOLVED_TIMEOUT"); 83 + if(timeVal !== undefined) { 84 + const time = Number.parseInt(timeVal); 85 + if(Number.isNaN(time)) { 86 + throw new Error(`Could not parse 'UNRESOLVED_TIMEOUT' ENV as a number`); 87 + } 88 + resolverTimeout = time; 89 + } 90 + } 91 + 59 92 return { 60 93 levelInTitle, 61 94 resolvedIndicator, 62 - allowedResolveTypes 95 + allowedResolveTypes, 96 + resolverTypes, 97 + resolverTimeout 63 98 } 64 99 }; 65 100 ··· 92 127 } 93 128 const allowedTruthyVals = resolvedTypesToVal(types); 94 129 return allowedTruthyVals.includes(alertResolved); 130 + } 131 + 132 + export const parseAlertTypesString = (str: string): AlertTypes[] => { 133 + let types: AlertTypes[] = []; 134 + 135 + const splitTypes = str.split(',').map(x => x.trim().toLocaleLowerCase()); 136 + // TODO don't have a good way to generate types from TS and don't want to handcode 137 + // const badTypes = splitTypes.filter(x => !asResolvedType(x)); 138 + // if(badTypes.length > 0) { 139 + // throw new Error(`Invalid resolve types found, values must be either 'resolved' or 'unresolved'. Invalid found: ${badTypes.join(',')}`); 140 + // } 141 + types = Array.from(new Set(splitTypes)) as AlertTypes[]; 142 + return types; 95 143 }
+16 -14
notifiers/discord/program.ts
··· 2 2 import { EmbedBuilder, WebhookClient } from "npm:discord.js"; 3 3 import { CommonAlert, parseAlert } from "../common/alertParser.ts"; 4 4 import path from "node:path"; 5 - import { alertResolvedAllowed, parseOptions } from "../common/options.ts"; 5 + import { parseOptions } from "../common/options.ts"; 6 + import { createNotifierPipe } from "../common/notifierUtils.ts"; 6 7 7 8 const program = () => { 8 9 const DISCORD_WEBHOOK: string = Deno.env.get("DISCORD_WEBHOOK") as string; ··· 69 70 } 70 71 }; 71 72 73 + const doAlert = async (data: CommonAlert, alert: Types.Alert) => { 74 + try { 75 + await pushAlert(data, alert.level); 76 + } catch (e) { 77 + console.debug("Komodo Alert Payload:", alert); 78 + console.error( 79 + new Error("Failed to push Alert to Discord", { cause: e }), 80 + ); 81 + } 82 + } 83 + 84 + const notifierPipe = createNotifierPipe(commonOpts); 85 + 72 86 const server = Deno.serve({ port: 7000 }, async (req) => { 73 87 const alert: Types.Alert = await req.json(); 74 88 console.log(`Recieved data from ${req.headers.get("host")}...`); ··· 83 97 return new Response(); 84 98 } 85 99 86 - if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { 87 - console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); 88 - return new Response(); 89 - } 90 - 91 - try { 92 - await pushAlert(data, alert.level); 93 - } catch (e) { 94 - console.debug("Komodo Alert Payload:", alert); 95 - console.error( 96 - new Error("Failed to push Alert to Gotify", { cause: e }), 97 - ); 98 - } 100 + await notifierPipe(alert, () => doAlert(data, alert)) 99 101 100 102 return new Response(); 101 103 });
+21 -18
notifiers/gotify/program.ts
··· 2 2 import { gotify } from "npm:gotify@1.1.0"; 3 3 import { CommonAlert, parseAlert } from "../common/alertParser.ts"; 4 4 import { titleAndSubtitle } from "../common/notifierBuilder.ts"; 5 - import { alertResolvedAllowed, parseOptions } from "../common/options.ts"; 5 + import { parseOptions } from "../common/options.ts"; 6 + import { createNotifierPipe } from "../common/notifierUtils.ts"; 6 7 7 8 const program = () => { 8 9 const GOTIFY_URL: string = Deno.env.get("GOTIFY_URL") as string; ··· 76 77 } 77 78 }; 78 79 80 + const doAlert = async (data: CommonAlert, alert: Types.Alert) => { 81 + try { 82 + await pushAlert( 83 + titleAndSubtitle(data), 84 + data.message ?? "", 85 + severityLevelPriority[alert.level], 86 + ); 87 + } catch (e) { 88 + console.debug("Komodo Alert Payload:", alert); 89 + console.error( 90 + new Error("Failed to push Alert to Gotify", { cause: e }), 91 + ); 92 + } 93 + 94 + } 95 + 96 + const notifierPipe = createNotifierPipe(commonOpts); 97 + 79 98 return Deno.serve({ port: 7000 }, async (req) => { 80 99 const alert: Types.Alert = await req.json(); 81 100 console.log(`Recieved data from ${req.headers.get("host")}...`); ··· 90 109 return new Response(); 91 110 } 92 111 93 - if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { 94 - console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); 95 - return new Response(); 96 - } 97 - 98 - try { 99 - await pushAlert( 100 - titleAndSubtitle(data), 101 - data.message ?? "", 102 - severityLevelPriority[alert.level], 103 - ); 104 - } catch (e) { 105 - console.debug("Komodo Alert Payload:", alert); 106 - console.error( 107 - new Error("Failed to push Alert to Gotify", { cause: e }), 108 - ); 109 - } 112 + await notifierPipe(alert, () => doAlert(data, alert)) 110 113 111 114 return new Response(); 112 115 });
+21 -18
notifiers/ntfy/program.ts
··· 2 2 import { Config, publish } from "npm:ntfy@1.7.0"; 3 3 import { CommonAlert, parseAlert } from "../common/alertParser.ts"; 4 4 import { titleAndSubtitle } from "../common/notifierBuilder.ts"; 5 - import { alertResolvedAllowed, parseOptions } from "../common/options.ts"; 5 + import { parseOptions } from "../common/options.ts"; 6 + import { createNotifierPipe } from "../common/notifierUtils.ts"; 6 7 7 8 const program = () => { 8 9 ··· 112 113 } 113 114 }; 114 115 115 - return Deno.serve({ port: 7000 }, async (req) => { 116 - const alert: Types.Alert = await req.json(); 117 - console.log(`Recieved data from ${req.headers.get("host")}...`); 118 116 119 - let data: CommonAlert; 120 - 121 - try { 122 - data = parseAlert(alert, commonOpts); 123 - } catch (e) { 124 - console.error(e); 125 - return new Response(); 126 - } 127 - 128 - if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) { 129 - console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`); 130 - return new Response(); 131 - } 132 - 117 + const doAlert = async (data: CommonAlert, alert: Types.Alert) => { 133 118 try { 134 119 await pushAlert( 135 120 titleAndSubtitle(data), ··· 144 129 new Error("Failed to push Alert to ntfy", { cause: e }), 145 130 ); 146 131 } 132 + } 133 + 134 + const notifierPipe = createNotifierPipe(commonOpts); 135 + 136 + return Deno.serve({ port: 7000 }, async (req) => { 137 + const alert: Types.Alert = await req.json(); 138 + console.log(`Recieved data from ${req.headers.get("host")}...`); 139 + 140 + let data: CommonAlert; 141 + 142 + try { 143 + data = parseAlert(alert, commonOpts); 144 + } catch (e) { 145 + console.error(e); 146 + return new Response(); 147 + } 148 + 149 + await notifierPipe(alert, () => doAlert(data, alert)) 147 150 148 151 return new Response(); 149 152 });
+12 -2
notifiers/runners/gotify.runner.ts
··· 1 1 import { expect } from "jsr:@std/expect"; 2 - import { ServerMem, StackImageUpdateAvailable } from "../tests/fixtures.ts"; 2 + import { StackImageUpdateAvailable, ServerCPU } from "../tests/fixtures.ts"; 3 3 import { program } from "../gotify/program.ts"; 4 + import { sleep } from "../common/dataUtils.ts"; 4 5 5 6 Deno.test({ 6 7 name: "Gotify - run memory alert", ··· 10 11 11 12 const req = new Request("http://127.0.0.1:7000", { 12 13 method: "POST", 13 - body: JSON.stringify(ServerMem), 14 + body: JSON.stringify({...ServerCPU, resolved: false}), 14 15 }); 15 16 const resp = await fetch(req); 16 17 expect(resp.ok).toBeTruthy(); 18 + 19 + await sleep(2400); 20 + 21 + const reqResolved = new Request("http://127.0.0.1:7000", { 22 + method: "POST", 23 + body: JSON.stringify({...ServerCPU, resolved: true}), 24 + }); 25 + const respResolved = await fetch(reqResolved); 26 + expect(respResolved.ok).toBeTruthy(); 17 27 } catch (e) { 18 28 throw e; 19 29 } finally {
+1 -1
notifiers/runners/ntfy.runner.ts
··· 3 3 import { program } from "../ntfy/program.ts"; 4 4 5 5 Deno.test({ 6 - name: "Ntfy - run memory alert", 6 + name: "Ntfy - run cpu alert", 7 7 async fn() { 8 8 const server = program(); 9 9 try {