diff --git a/server/frontend/src/Components/Plugins/Plugins.jsx b/server/frontend/src/Components/Plugins/Plugins.jsx index 62dfc6c..8d3dd47 100644 --- a/server/frontend/src/Components/Plugins/Plugins.jsx +++ b/server/frontend/src/Components/Plugins/Plugins.jsx @@ -1,21 +1,19 @@ 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 = () => { 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 (
-
- { clickAndNavigate("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" - > - - - - Back to hosts list - -
+ + + + + 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 ? ( + + Settings + + ) : ( + + 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} +
+
+ + 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..d01d0fd 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,15 +690,24 @@ 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; + : { + params: globalPluginSettings?.params || {}, + enabledEvents: globalPluginSettings?.enabledEvents ?? [], + enabled: globalPluginSettings?.enabled, + }; return res.status(200).json({ status: "success", @@ -750,22 +760,34 @@ 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, - enabledEvents: input.events ? Object.keys(input.events) : [], + 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 || {}, + // undefined = inherit global enabledEvents; explicit array = group override + ...(input.events ? { enabledEvents: 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 and not already initialized + const globallyEnabled = database.data.pluginSettings.find((ps) => ps.id === plugin.id)?.enabled; + if (!globallyEnabled && !PluginManagerSingleton()._initializedPlugins?.has(plugin.id)) { + await plugin.onPluginEnabled?.(); + PluginManagerSingleton()._initializedPlugins?.add(plugin.id); + } + } else { + database.data.groupPluginSettings[idx] = entry; } if (input.notify) { const globalSettings = database.data.pluginSettings.find( @@ -1257,7 +1279,6 @@ router.get( data: (database.data.hostGroups || []).map((g) => ({ id: g.id, name: g.name, - slackWebhook: g.slackWebhook, createdAt: g.createdAt, })), }); @@ -1267,7 +1288,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 +1298,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 +1310,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 +1318,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 +1341,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..5c23651 100644 --- a/server/src/database.js +++ b/server/src/database.js @@ -32,6 +32,36 @@ db.read = async function () { }; db.data.pluginSettings ||= []; db.data.hostGroups ||= []; + db.data.groupPluginSettings ||= []; + + // Migrate legacy slackSettings/slackWebhook from group objects to groupPluginSettings + let migrated = false; + 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) { + const entry = { + groupId: group.id, + pluginId: 'slack-notifications', + params: group.slackSettings?.params || (group.slackWebhook ? { webhook: group.slackWebhook } : {}), + }; + // Only set enabledEvents if explicitly configured; omit the property to inherit global settings + if (group.slackSettings?.enabledEvents) { + entry.enabledEvents = group.slackSettings.enabledEvents; + } + db.data.groupPluginSettings.push(entry); + } + delete group.slackSettings; + delete group.slackWebhook; + delete group.channelName; + migrated = true; + } + } + if (migrated) { + await db.write(); + } }; export default db; \ No newline at end of file diff --git a/server/src/pluginManager.js b/server/src/pluginManager.js index 1448154..88994e4 100644 --- a/server/src/pluginManager.js +++ b/server/src/pluginManager.js @@ -48,15 +48,20 @@ class PluginManager { return p.default; }) ); + const pluginsUsedInGroups = new Set( + (database.data.groupPluginSettings || []).map((s) => s.pluginId) + ); + this._initializedPlugins = new Set(); 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) => { + this._initializedPlugins.add(p.id); + return p.onPluginEnabled && p.onPluginEnabled(); }) - .map((p) => p.onPluginEnabled && p.onPluginEnabled()) ); } @@ -119,25 +124,34 @@ 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 }, + // Only override enabledEvents when explicitly set in group entry + ...('enabledEvents' in groupEntry ? { 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,30 +215,26 @@ 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) => { - return p.settings.enabled && enabledPluginsArr.includes(p.plugin.id); + const hasGroupOverride = !!hostGroup?.pluginSettings?.[p.plugin.id]; + return (hasGroupOverride || 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); + console.error("Error in plugin", p.plugin.id, e, "stack:", e.stack); } }) ); diff --git a/server/src/plugins/slack.js b/server/src/plugins/slack.js index 2dd1263..ceab141 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); + await this.sendMessage(settings, text); } catch (e) {console.log(e)} }, diff --git a/server/src/utils.js b/server/src/utils.js index 482e4b6..ae3caf9 100644 --- a/server/src/utils.js +++ b/server/src/utils.js @@ -790,7 +790,17 @@ 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) => { + const entry = { params: s.params }; + if (Object.prototype.hasOwnProperty.call(s, 'enabledEvents')) { + entry.enabledEvents = s.enabledEvents; + } + pluginSettings[s.pluginId] = entry; + }); + return { id: g.id, name: g.name, pluginSettings }; }; export const getEffectiveSettingsForHost = (host) => {