From 599638d7febeff91f97607a85a6ac8bc64f5a61b Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Thu, 7 May 2026 12:10:02 +0300 Subject: [PATCH 1/4] feat: Migrate legacy Slack settings to group-specific plugin settings and enhance plugin management --- .../src/Components/Plugins/Plugins.jsx | 306 +++++++++--------- server/src/apinext.js | 68 ++-- server/src/database.js | 21 ++ server/src/pluginManager.js | 54 ++-- server/src/plugins/slack.js | 21 +- server/src/utils.js | 6 +- 6 files changed, 257 insertions(+), 219 deletions(-) diff --git a/server/frontend/src/Components/Plugins/Plugins.jsx b/server/frontend/src/Components/Plugins/Plugins.jsx index 62dfc6c..1f1cde4 100644 --- a/server/frontend/src/Components/Plugins/Plugins.jsx +++ b/server/frontend/src/Components/Plugins/Plugins.jsx @@ -6,16 +6,14 @@ import { useNavigate } from "react-router-dom"; const Plugins = () => { const navigate = useNavigate(""); - const clickAndNavigate = function (path) { - navigate(`/${path}`); - }; const [pluginsArr, setPluginsArr] = useState([]); const [hostGroups, setHostGroups] = useState([]); const [addGroupOpen, setAddGroupOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(""); + const [addPluginGroupId, setAddPluginGroupId] = useState(null); - const getPlugginsArr = async function () { + const load = async () => { const data = await getData("plugins"); if (data) { setPluginsArr(data.plugins); @@ -23,155 +21,195 @@ const Plugins = () => { } }; - useEffect(() => { - getPlugginsArr(); - }, []); + useEffect(() => { load(); }, []); - const disablePlugin = async function (e) { - const data = await apiFetch({ id: e.target.id }, `plugin_disable`); - if (data.status === "success") { - getPlugginsArr(); - } + const disablePlugin = async (id) => { + const data = await apiFetch({ id }, `plugin_disable`); + if (data.status === "success") load(); }; const createGroup = async () => { if (!newGroupName.trim()) return; - await apiFetch({ name: newGroupName.trim(), slackWebhook: "" }, "host_groups"); + await apiFetch({ name: newGroupName.trim() }, "host_groups"); setNewGroupName(""); setAddGroupOpen(false); - getPlugginsArr(); + load(); }; const deleteGroup = async (id) => { if (!window.confirm("Delete this group? Hosts will become ungrouped.")) return; await apiFetch({}, `host_groups/${id}/delete`); - getPlugginsArr(); + load(); + }; + + const removePluginFromGroup = async (groupId, pluginId) => { + await apiFetch({}, `host_groups/${groupId}/plugin/${pluginId}/remove`); + load(); }; - const slackPlugin = pluginsArr.find((p) => p.id === "slack-notifications"); + const availablePlugins = pluginsArr; return (
- + navigate("/home")} + className="text-white dark:text-gray-800 bg-green-700 hover:bg-green-800 focus:ring-4 focus:outline-none + focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 + text-center mr-3 md:mr-0 dark:bg-green-400 dark:hover:bg-green-500 dark:focus:ring-green-800 flex items-center cursor-pointer" + > + + + + Back to hosts list +
-
-
- Available plugins -
-
+ {/* Plugins section */} +
+ Available plugins +
-
- {pluginsArr.map((pl) => { - return ( -
- product image -
-
- {pl.pluginEnabled ? ( - { clickAndNavigate(`plugin/${pl.id}`); }} - className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 cursor-pointer" - > - Settings - - ) : ( - { clickAndNavigate(`plugin/${pl.id}`); }} - className="text-white w-full bg-green-700 hover:bg-green-800 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800 cursor-pointer" - > - Enable - - )} - {pl.pluginEnabled ? ( - - ) : null} -
+
+ {pluginsArr.map((pl) => ( +
+ {pl.id} +
+ {pl.pluginEnabled ? ( + navigate(`/plugin/${pl.id}`)} + className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 cursor-pointer" + > + Settings + + ) : ( + navigate(`/plugin/${pl.id}`)} + className="text-white w-full bg-green-700 hover:bg-green-800 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800 cursor-pointer" + > + Enable + + )} + {pl.pluginEnabled && ( + + )}
- ); - })} +
+ ))} +
+ + {/* Groups section */} +
+ Groups +
- {/* Host group cards */} +
{hostGroups.map((g) => (
- {slackPlugin && ( - Slack +
+

{g.name}

+
+ + {/* Installed plugins */} + {g.installedPlugins && g.installedPlugins.length > 0 && ( +
+ {g.installedPlugins.map((pluginId) => { + const pl = pluginsArr.find((p) => p.id === pluginId); + return ( +
+
+ {pl && {pluginId}} + {pluginId} +
+
+ navigate(`/plugin/${pluginId}?groupId=${g.id}`)} + className="text-xs text-center w-full text-white bg-blue-600 hover:bg-blue-700 rounded px-2 py-1 cursor-pointer" + > + Settings + + +
+
+ ); + })} +
)} -
-

{g.name}

- {g.channelName && ( -

{g.channelName}

+ + {/* Add plugin button */} +
+ {addPluginGroupId === g.id ? ( +
+ + +
+ ) : ( +
+ + +
)}
-
- { navigate(`/plugin/slack-notifications?groupId=${g.id}`); }} - className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 cursor-pointer" - > - Settings - - -
))} {/* Add group card */} -
+
{!addGroupOpen ? ( ) : (
@@ -182,6 +220,7 @@ const Plugins = () => { className="w-full mb-3 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:text-white" placeholder="Group name" onKeyDown={(e) => e.key === "Enter" && createGroup()} + autoFocus />
- -
); }; diff --git a/server/src/apinext.js b/server/src/apinext.js index 996de31..28825bf 100644 --- a/server/src/apinext.js +++ b/server/src/apinext.js @@ -660,8 +660,9 @@ router.get( hostGroups: (database.data.hostGroups || []).map((g) => ({ id: g.id, name: g.name, - channelName: g.channelName || null, - hasSettings: !!g.slackSettings, + installedPlugins: (database.data.groupPluginSettings || []) + .filter((s) => s.groupId === g.id) + .map((s) => s.pluginId), })), }); }) @@ -689,12 +690,17 @@ router.get( (ps) => ps.id === plugin.id ); + const groupPluginEntry = group + ? (database.data.groupPluginSettings || []).find( + (s) => s.groupId === groupId && s.pluginId === plugin.id + ) + : null; + // For group-specific settings, use group's own params (not the global ones) const pluginSettings = group ? { - ...globalPluginSettings, - params: group.slackSettings?.params || {}, - enabledEvents: group.slackSettings?.enabledEvents || globalPluginSettings?.enabledEvents || [], + params: groupPluginEntry?.params || {}, + enabledEvents: groupPluginEntry?.enabledEvents || globalPluginSettings?.enabledEvents || [], enabled: globalPluginSettings?.enabled, } : globalPluginSettings; @@ -750,22 +756,30 @@ router.post( const input = parseNestedForm(req.body); const groupId = req.query.groupId; - // If saving group-specific settings for slack - if (groupId && plugin.id === "slack-notifications") { + // If saving group-specific plugin settings + if (groupId) { const group = (database.data.hostGroups || []).find((g) => g.id === groupId); if (!group) { return res.status(404).json({ error: "Group not found" }); } - group.slackSettings = { - params: input.params, + database.data.groupPluginSettings ||= []; + const idx = database.data.groupPluginSettings.findIndex( + (s) => s.groupId === groupId && s.pluginId === plugin.id + ); + const entry = { + groupId, + pluginId: plugin.id, + params: input.params || {}, enabledEvents: input.events ? Object.keys(input.events) : [], }; - // Use group webhook param as the slackWebhook for notification routing - if (input.params?.webhook) { - group.slackWebhook = input.params.webhook; - } - if (input.params?.channel_name !== undefined) { - group.channelName = input.params.channel_name; + const isNew = idx === -1; + if (isNew) { + database.data.groupPluginSettings.push(entry); + // init plugin if not globally enabled + const globallyEnabled = database.data.pluginSettings.find((ps) => ps.id === plugin.id)?.enabled; + if (!globallyEnabled) await plugin.onPluginEnabled?.(); + } else { + database.data.groupPluginSettings[idx] = entry; } if (input.notify) { const globalSettings = database.data.pluginSettings.find( @@ -1257,7 +1271,6 @@ router.get( data: (database.data.hostGroups || []).map((g) => ({ id: g.id, name: g.name, - slackWebhook: g.slackWebhook, createdAt: g.createdAt, })), }); @@ -1267,7 +1280,7 @@ router.get( router.post( "/host_groups", mustBeAuthorizedView(async (req, res) => { - const { name, slackWebhook } = req.body || {}; + const { name } = req.body || {}; if (!name || !String(name).trim()) { return res .status(400) @@ -1277,7 +1290,6 @@ router.post( const group = { id: uuidv4(), name: String(name).trim(), - slackWebhook: (slackWebhook || "").trim(), createdAt: new Date().getTime(), }; database.data.hostGroups.push(group); @@ -1290,7 +1302,7 @@ router.post( "/host_groups/:id", mustBeAuthorizedView(async (req, res) => { const { id } = req.params; - const { name, slackWebhook } = req.body || {}; + const { name } = req.body || {}; const group = (database.data.hostGroups || []).find((g) => g.id === id); if (!group) { return res @@ -1298,8 +1310,6 @@ router.post( .json({ status: "rejected", code: 404, error: "group not found" }); } if (typeof name === "string" && name.trim()) group.name = name.trim(); - if (typeof slackWebhook === "string") - group.slackWebhook = slackWebhook.trim(); await database.write(); return res.status(200).json({ status: "success", code: 200, data: group }); }) @@ -1323,6 +1333,22 @@ router.post( (database.data.httpMonitoringData || []).forEach((h) => { if (h.groupId === id) h.groupId = null; }); + // remove all group plugin settings for this group + database.data.groupPluginSettings = (database.data.groupPluginSettings || []).filter( + (s) => s.groupId !== id + ); + await database.write(); + return res.status(200).json({ status: "success", code: 200 }); + }) +); + +router.post( + "/host_groups/:groupId/plugin/:pluginId/remove", + mustBeAuthorizedView(async (req, res) => { + const { groupId, pluginId } = req.params; + database.data.groupPluginSettings = (database.data.groupPluginSettings || []).filter( + (s) => !(s.groupId === groupId && s.pluginId === pluginId) + ); await database.write(); return res.status(200).json({ status: "success", code: 200 }); }) diff --git a/server/src/database.js b/server/src/database.js index ef9f99a..9161b12 100644 --- a/server/src/database.js +++ b/server/src/database.js @@ -32,6 +32,27 @@ db.read = async function () { }; db.data.pluginSettings ||= []; db.data.hostGroups ||= []; + db.data.groupPluginSettings ||= []; + + // Migrate legacy slackSettings/slackWebhook from group objects to groupPluginSettings + for (const group of db.data.hostGroups) { + if (group.slackSettings || group.slackWebhook) { + const alreadyMigrated = db.data.groupPluginSettings.some( + (s) => s.groupId === group.id && s.pluginId === 'slack-notifications' + ); + if (!alreadyMigrated) { + db.data.groupPluginSettings.push({ + groupId: group.id, + pluginId: 'slack-notifications', + params: group.slackSettings?.params || (group.slackWebhook ? { webhook: group.slackWebhook } : {}), + enabledEvents: group.slackSettings?.enabledEvents || [], + }); + } + delete group.slackSettings; + delete group.slackWebhook; + delete group.channelName; + } + } }; export default db; \ No newline at end of file diff --git a/server/src/pluginManager.js b/server/src/pluginManager.js index 1448154..f5f4ab5 100644 --- a/server/src/pluginManager.js +++ b/server/src/pluginManager.js @@ -48,13 +48,14 @@ class PluginManager { return p.default; }) ); + const pluginsUsedInGroups = new Set( + (database.data.groupPluginSettings || []).map((s) => s.pluginId) + ); await Promise.all( this.plugins .filter((p) => { - return ( - database.data.pluginSettings.find((ps) => ps.id === p.id) - ?.enabled ?? false - ); + const globallyEnabled = database.data.pluginSettings.find((ps) => ps.id === p.id)?.enabled ?? false; + return globallyEnabled || pluginsUsedInGroups.has(p.id); }) .map((p) => p.onPluginEnabled && p.onPluginEnabled()) ); @@ -119,25 +120,33 @@ class PluginManager { const plugins = this.plugins .map((p) => { - const settings = + const globalSettings = database.data.pluginSettings.find((ps) => ps.id === p.id) || {}; - return { - plugin: p, - settings, - }; + // Merge group-specific plugin settings on top of global settings + const groupEntry = newData.HOST_GROUP?.pluginSettings?.[p.id]; + const settings = groupEntry + ? { + ...globalSettings, + params: { ...globalSettings.params, ...groupEntry.params }, + enabledEvents: groupEntry.enabledEvents, + } + : globalSettings; + return { plugin: p, settings }; }) .filter((p) => { const effectiveEnabledEvents = p.plugin.getEffectiveEnabledEvents ? p.plugin.getEffectiveEnabledEvents({ data: newData, settings: p.settings }) : p.settings.enabledEvents; + const hasGroupOverride = !!newData.HOST_GROUP?.pluginSettings?.[p.plugin.id]; + const effectiveEnabled = hasGroupOverride || p.settings.enabled; const pass = - p.settings.enabled && + effectiveEnabled && effectiveEnabledEvents?.includes(eventType) && !hostEvents.includes(eventType) && enabledPlugins.includes(p.plugin.id); if (!pass) { - console.log(`[PluginManager] Skip plugin ${p.plugin.id}: enabled=${p.settings.enabled} hasEvent=${effectiveEnabledEvents?.includes(eventType)} notSuppressed=${!hostEvents.includes(eventType)} inEnabledList=${enabledPlugins.includes(p.plugin.id)}`); + console.log(`[PluginManager] Skip plugin ${p.plugin.id}: enabled=${effectiveEnabled}(group=${hasGroupOverride}) hasEvent=${effectiveEnabledEvents?.includes(eventType)} notSuppressed=${!hostEvents.includes(eventType)} inEnabledList=${enabledPlugins.includes(p.plugin.id)}`); } return pass; }); @@ -201,28 +210,21 @@ class PluginManager { } const enabledPluginsArr = getEnabledPluginsForHost(); - // plugins var is only enabled plugin for this event based on enabledPlugins const plugins = this.plugins .map((p) => { - const settings = - database.data.pluginSettings.find((ps) => ps.id === p.id) || {}; - return { - plugin: p, - settings, - }; + const globalSettings = database.data.pluginSettings.find((ps) => ps.id === p.id) || {}; + const groupEntry = hostGroup?.pluginSettings?.[p.id]; + const settings = groupEntry + ? { ...globalSettings, params: { ...globalSettings.params, ...groupEntry.params } } + : globalSettings; + return { plugin: p, settings }; }) + .filter((p) => p.settings.enabled && enabledPluginsArr.includes(p.plugin.id)); - .filter((p) => { - return p.settings.enabled && enabledPluginsArr.includes(p.plugin.id); - }); await Promise.all( plugins.map(async (p) => { try { - const webhookOverride = - p.plugin.id === "slack-notifications" && hostGroup?.slackWebhook - ? hostGroup.slackWebhook - : undefined; - await p.plugin.sendMessage(p.settings, rssFormatedMessage, webhookOverride); + await p.plugin.sendMessage(p.settings, rssFormatedMessage); } catch (e) { console.error("Error in plugin", p.id, e, "stack:", e.stack); } diff --git a/server/src/plugins/slack.js b/server/src/plugins/slack.js index 2dd1263..8692412 100644 --- a/server/src/plugins/slack.js +++ b/server/src/plugins/slack.js @@ -119,13 +119,7 @@ Webhook is URL which look like this: }, ], - getEffectiveEnabledEvents({ data, settings }) { - const groupSlackSettings = data?.HOST_GROUP?.slackSettings; - if (groupSlackSettings) { - return groupSlackSettings.enabledEvents || []; - } - return settings.enabledEvents; - }, + // getEffectiveEnabledEvents not needed — pluginManager merges group settings into `settings` automatically async sendMessage(settings, text, webhookOverride) { if(!text) { @@ -158,19 +152,12 @@ Webhook is URL which look like this: }, // main event handling is done here + // `settings` already has group overrides merged in by pluginManager async handleEvent({ eventType, data, settings }) { - // Use group-specific params (message templates, webhook) when available - const groupParams = data?.HOST_GROUP?.slackSettings?.params; - const effectiveParams = groupParams - ? { ...settings.params, ...groupParams } - : settings.params; - - const template = this.hbs.compile(effectiveParams[`${eventType}_message`], {noEscape: true}); + const template = this.hbs.compile(settings.params[`${eventType}_message`], {noEscape: true}); const text = template(data); - - const webhookOverride = data?.HOST_GROUP?.slackWebhook; try { - this.sendMessage(settings, text, webhookOverride); + this.sendMessage(settings, text); } catch (e) {console.log(e)} }, diff --git a/server/src/utils.js b/server/src/utils.js index 482e4b6..25dc650 100644 --- a/server/src/utils.js +++ b/server/src/utils.js @@ -790,7 +790,11 @@ export const getGroupForHost = (host) => { const groups = database.data.hostGroups || []; const g = groups.find((gr) => gr.id === host.groupId); if (!g) return null; - return { id: g.id, name: g.name, slackWebhook: g.slackWebhook, slackSettings: g.slackSettings || null }; + const pluginSettings = {}; + (database.data.groupPluginSettings || []) + .filter((s) => s.groupId === g.id) + .forEach((s) => { pluginSettings[s.pluginId] = { params: s.params, enabledEvents: s.enabledEvents }; }); + return { id: g.id, name: g.name, pluginSettings }; }; export const getEffectiveSettingsForHost = (host) => { From 398dbf0bd59ba746c9046ccd4ae29f42d5cf21b9 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Thu, 7 May 2026 13:01:26 +0300 Subject: [PATCH 2/4] feat: Replace navigate with Link component for improved routing in Plugins --- .../src/Components/Plugins/Plugins.jsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/server/frontend/src/Components/Plugins/Plugins.jsx b/server/frontend/src/Components/Plugins/Plugins.jsx index 1f1cde4..8d3dd47 100644 --- a/server/frontend/src/Components/Plugins/Plugins.jsx +++ b/server/frontend/src/Components/Plugins/Plugins.jsx @@ -1,7 +1,7 @@ import React from "react"; import { useState, useEffect } from "react"; import { apiFetch, getData } from "../../../FetchApi"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; const Plugins = () => { @@ -54,17 +54,17 @@ const Plugins = () => {
{/* Plugins section */} @@ -81,19 +81,19 @@ const Plugins = () => { {pl.id}
- navigate(`/plugin/${pluginId}?groupId=${g.id}`)} - className="text-xs text-center w-full text-white bg-blue-600 hover:bg-blue-700 rounded px-2 py-1 cursor-pointer" + Settings - +