Files
team/server/channels/app/plugin_statuses.go
a6070f1da0 MM-69104: Elide plugin_statuses_changed payload (#36966)
* MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

The plugin_statuses_changed websocket event broadcast the entire cluster-wide
plugin status slice over the best-effort (UDP) cluster transport. With enough
installed plugins the serialized payload exceeded the gossip datagram size and
was silently dropped with EMSGSIZE ("message too long"), leaving admin clients
on peer nodes with stale plugin state during plugin reinstall/restart churn.

The event is admin-only and only feeds the System Console plugin views, so
rather than push the whole slice, publish it as a payload-free signal sent
reliably over the cluster. The Plugin Management and Marketplace views subscribe
via the useWebSocket hook and debounce a refetch of a full, cluster-wide status
from the existing REST endpoint, pulling the data on demand only while a relevant
view is mounted.

* MM-69104: keep plugin_statuses as an empty array for client compatibility

Some clients call array methods on plugin_statuses (e.g. the Boards/Focalboard
plugin does data.plugin_statuses.find(...)). Omitting the field entirely makes that
throw, so send a non-nil empty slice that serializes as [] rather than null. The
payload stays effectively empty, so the cluster-broadcast size fix is preserved.

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

* Apply suggestions from code review

Co-authored-by: Alejandro García Montoro <[email protected]>

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

use shared useDebounce hook in usePluginStatusesSync

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

trim docstring

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

return pluginStatuses from hook

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

cover hook return value

---------

Co-authored-by: Alejandro García Montoro <[email protected]>
2026-06-10 13:07:53 -03:00

109 lines
4.1 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// GetPluginStatus returns the status for a plugin installed on this server.
func (ch *Channels) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) {
pluginsEnvironment := ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return nil, model.NewAppError("GetPluginStatus", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
pluginStatuses, err := pluginsEnvironment.Statuses()
if err != nil {
return nil, model.NewAppError("GetPluginStatus", "app.plugin.get_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, status := range pluginStatuses {
if status.PluginId == id {
// Add our cluster ID
if ch.srv.platform.Cluster() != nil {
status.ClusterId = ch.srv.platform.Cluster().GetClusterId()
}
return status, nil
}
}
return nil, model.NewAppError("GetPluginStatus", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound)
}
// GetPluginStatus returns the status for a plugin installed on this server.
func (a *App) GetPluginStatus(id string) (*model.PluginStatus, *model.AppError) {
return a.ch.GetPluginStatus(id)
}
// GetPluginStatuses returns the status for plugins installed on this server.
func (ch *Channels) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
pluginsEnvironment := ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
pluginStatuses, err := pluginsEnvironment.Statuses()
if err != nil {
return nil, model.NewAppError("GetPluginStatuses", "app.plugin.get_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Add our cluster ID
for _, status := range pluginStatuses {
if ch.srv.platform.Cluster() != nil {
status.ClusterId = ch.srv.platform.Cluster().GetClusterId()
} else {
status.ClusterId = ""
}
}
return pluginStatuses, nil
}
// GetPluginStatuses returns the status for plugins installed on this server.
func (a *App) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
return a.ch.GetPluginStatuses()
}
// GetClusterPluginStatuses returns the status for plugins installed anywhere in the cluster.
func (a *App) GetClusterPluginStatuses() (model.PluginStatuses, *model.AppError) {
return a.ch.getClusterPluginStatuses()
}
func (ch *Channels) getClusterPluginStatuses() (model.PluginStatuses, *model.AppError) {
pluginStatuses, err := ch.GetPluginStatuses()
if err != nil {
return nil, err
}
if ch.srv.platform.Cluster() != nil && *ch.cfgSvc.Config().ClusterSettings.Enable {
clusterPluginStatuses, err := ch.srv.platform.Cluster().GetPluginStatuses()
if err != nil {
return nil, model.NewAppError("GetClusterPluginStatuses", "app.plugin.get_cluster_plugin_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
pluginStatuses = append(pluginStatuses, clusterPluginStatuses...)
}
return pluginStatuses, nil
}
// notifyPluginStatusesChanged signals system admins that plugin statuses have changed without
// carrying the full status slice. That slice (status for every installed plugin, cluster-wide)
// routinely overflows the best-effort (UDP) cluster transport, so admin clients refetch it on
// demand instead. The signal is sent reliably (TCP) and only to system admins (ContainsSensitiveData).
func (ch *Channels) notifyPluginStatusesChanged() {
message := model.NewWebSocketEvent(model.WebsocketEventPluginStatusesChanged, "", "", "", nil, "")
// Compatibility shim: some clients (e.g. the Boards/Focalboard plugin) call array methods on
// plugin_statuses, so include an empty (non-nil, to serialize as [] rather than null) slice
// rather than omitting the field. The payload is intentionally always empty.
message.Add("plugin_statuses", model.PluginStatuses{})
message.GetBroadcast().ContainsSensitiveData = true
message.GetBroadcast().ReliableClusterSend = true
ch.srv.platform.Publish(message)
}