diff --git a/cmd/prometheus/main.go b/cmd/prometheus/main.go index 0e05b843d0..dd068b86c5 100644 --- a/cmd/prometheus/main.go +++ b/cmd/prometheus/main.go @@ -78,6 +78,7 @@ import ( "github.com/prometheus/prometheus/util/logging" prom_runtime "github.com/prometheus/prometheus/util/runtime" "github.com/prometheus/prometheus/web" + "github.com/prometheus/prometheus/web/api" ) var ( @@ -273,13 +274,17 @@ func main() { ) } + notifs := api.NewNotifications(prometheus.DefaultRegisterer) + cfg := flagConfig{ notifier: notifier.Options{ Registerer: prometheus.DefaultRegisterer, }, web: web.Options{ - Registerer: prometheus.DefaultRegisterer, - Gatherer: prometheus.DefaultGatherer, + Registerer: prometheus.DefaultRegisterer, + Gatherer: prometheus.DefaultGatherer, + NotificationsSub: notifs.Sub, + NotificationsGetter: notifs.Get, }, promlogConfig: promlog.Config{}, } @@ -1078,6 +1083,14 @@ func main() { } } + callback := func(success bool) { + if success { + notifs.DeleteNotification(api.ConfigurationUnsuccessful) + return + } + notifs.AddNotification(api.ConfigurationUnsuccessful) + } + g.Add( func() error { <-reloadReady.C @@ -1085,7 +1098,7 @@ func main() { for { select { case <-hup: - if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, reloaders...); err != nil { + if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, callback, reloaders...); err != nil { level.Error(logger).Log("msg", "Error reloading config", "err", err) } else if cfg.enableAutoReload { if currentChecksum, err := config.GenerateChecksum(cfg.configFile); err == nil { @@ -1095,7 +1108,7 @@ func main() { } } case rc := <-webHandler.Reload(): - if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, reloaders...); err != nil { + if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, callback, reloaders...); err != nil { level.Error(logger).Log("msg", "Error reloading config", "err", err) rc <- err } else { @@ -1120,7 +1133,7 @@ func main() { } level.Info(logger).Log("msg", "Configuration file change detected, reloading the configuration.") - if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, reloaders...); err != nil { + if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, callback, reloaders...); err != nil { level.Error(logger).Log("msg", "Error reloading config", "err", err) } else { checksum = currentChecksum @@ -1150,7 +1163,7 @@ func main() { return nil } - if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, reloaders...); err != nil { + if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, func(bool) {}, reloaders...); err != nil { return fmt.Errorf("error loading config from %q: %w", cfg.configFile, err) } @@ -1376,7 +1389,7 @@ type reloader struct { reloader func(*config.Config) error } -func reloadConfig(filename string, expandExternalLabels, enableExemplarStorage bool, logger log.Logger, noStepSuqueryInterval *safePromQLNoStepSubqueryInterval, rls ...reloader) (err error) { +func reloadConfig(filename string, expandExternalLabels, enableExemplarStorage bool, logger log.Logger, noStepSuqueryInterval *safePromQLNoStepSubqueryInterval, callback func(bool), rls ...reloader) (err error) { start := time.Now() timings := []interface{}{} level.Info(logger).Log("msg", "Loading configuration file", "filename", filename) @@ -1385,8 +1398,10 @@ func reloadConfig(filename string, expandExternalLabels, enableExemplarStorage b if err == nil { configSuccess.Set(1) configSuccessTime.SetToCurrentTime() + callback(true) } else { configSuccess.Set(0) + callback(false) } }() diff --git a/web/api/notifications.go b/web/api/notifications.go new file mode 100644 index 0000000000..47f29f6ebe --- /dev/null +++ b/web/api/notifications.go @@ -0,0 +1,176 @@ +// Copyright 2024 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + ConfigurationUnsuccessful = "Configuration reload has failed." +) + +// Notification represents an individual notification message. +type Notification struct { + Text string `json:"text"` + Date time.Time `json:"date"` + Active bool `json:"active"` +} + +// Notifications stores a list of Notification objects. +// It also manages live subscribers that receive notifications via channels. +type Notifications struct { + mu sync.Mutex + notifications []Notification + subscribers map[chan Notification]struct{} // Active subscribers. + + subscriberGauge prometheus.Gauge + notificationsSent prometheus.Counter + notificationsDropped prometheus.Counter +} + +// NewNotifications creates a new Notifications instance. +func NewNotifications(reg prometheus.Registerer) *Notifications { + n := &Notifications{ + subscribers: make(map[chan Notification]struct{}), + subscriberGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "prometheus", + Subsystem: "api", + Name: "notification_active_subscribers", + Help: "The current number of active notification subscribers.", + }), + notificationsSent: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "prometheus", + Subsystem: "api", + Name: "notification_updates_sent_total", + Help: "Total number of notification updates sent.", + }), + notificationsDropped: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "prometheus", + Subsystem: "api", + Name: "notification_updates_dropped_total", + Help: "Total number of notification updates dropped.", + }), + } + + if reg != nil { + reg.MustRegister(n.subscriberGauge, n.notificationsSent, n.notificationsDropped) + } + + return n +} + +// AddNotification adds a new notification or updates the timestamp if it already exists. +func (n *Notifications) AddNotification(text string) { + n.mu.Lock() + defer n.mu.Unlock() + + for i, notification := range n.notifications { + if notification.Text == text { + n.notifications[i].Date = time.Now() + + n.notifySubscribers(n.notifications[i]) + return + } + } + + newNotification := Notification{ + Text: text, + Date: time.Now(), + Active: true, + } + n.notifications = append(n.notifications, newNotification) + + n.notifySubscribers(newNotification) +} + +// notifySubscribers sends a notification to all active subscribers. +func (n *Notifications) notifySubscribers(notification Notification) { + for sub := range n.subscribers { + // Non-blocking send to avoid subscriber blocking issues. + n.notificationsSent.Inc() + select { + case sub <- notification: + // Notification sent to the subscriber. + default: + // Drop the notification if the subscriber's channel is full. + n.notificationsDropped.Inc() + } + } +} + +// DeleteNotification removes the first notification that matches the provided text. +// The deleted notification is sent to subscribers with Active: false before being removed. +func (n *Notifications) DeleteNotification(text string) { + n.mu.Lock() + defer n.mu.Unlock() + + // Iterate through the notifications to find the matching text. + for i, notification := range n.notifications { + if notification.Text == text { + // Mark the notification as inactive and notify subscribers. + notification.Active = false + n.notifySubscribers(notification) + + // Remove the notification from the list. + n.notifications = append(n.notifications[:i], n.notifications[i+1:]...) + return + } + } +} + +// Get returns a copy of the list of notifications for safe access outside the struct. +func (n *Notifications) Get() []Notification { + n.mu.Lock() + defer n.mu.Unlock() + + // Return a copy of the notifications slice to avoid modifying the original slice outside. + notificationsCopy := make([]Notification, len(n.notifications)) + copy(notificationsCopy, n.notifications) + return notificationsCopy +} + +// Sub allows a client to subscribe to live notifications. +// It returns a channel where the subscriber will receive notifications and a function to unsubscribe. +// Each subscriber has its own goroutine to handle notifications and prevent blocking. +func (n *Notifications) Sub() (<-chan Notification, func()) { + ch := make(chan Notification, 10) // Buffered channel to prevent blocking. + + n.mu.Lock() + // Add the new subscriber to the list. + n.subscribers[ch] = struct{}{} + n.subscriberGauge.Set(float64(len(n.subscribers))) + + // Send all current notifications to the new subscriber. + for _, notification := range n.notifications { + ch <- notification + } + n.mu.Unlock() + + // Unsubscribe function to remove the channel from subscribers. + unsubscribe := func() { + n.mu.Lock() + defer n.mu.Unlock() + + // Close the channel and remove it from the subscribers map. + close(ch) + delete(n.subscribers, ch) + n.subscriberGauge.Set(float64(len(n.subscribers))) + } + + return ch, unsubscribe +} diff --git a/web/api/notifications_test.go b/web/api/notifications_test.go new file mode 100644 index 0000000000..7aa5961638 --- /dev/null +++ b/web/api/notifications_test.go @@ -0,0 +1,192 @@ +// Copyright 2024 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestNotificationLifecycle tests adding, modifying, and deleting notifications. +func TestNotificationLifecycle(t *testing.T) { + notifs := NewNotifications(nil) + + // Add a notification. + notifs.AddNotification("Test Notification 1") + + // Check if the notification was added. + notifications := notifs.Get() + require.Len(t, notifications, 1, "Expected 1 notification after addition.") + require.Equal(t, "Test Notification 1", notifications[0].Text, "Notification text mismatch.") + require.True(t, notifications[0].Active, "Expected notification to be active.") + + // Modify the notification. + notifs.AddNotification("Test Notification 1") + notifications = notifs.Get() + require.Len(t, notifications, 1, "Expected 1 notification after modification.") + + // Delete the notification. + notifs.DeleteNotification("Test Notification 1") + notifications = notifs.Get() + require.Empty(t, notifications, "Expected no notifications after deletion.") +} + +// TestSubscriberReceivesNotifications tests that a subscriber receives notifications, including modifications and deletions. +func TestSubscriberReceivesNotifications(t *testing.T) { + notifs := NewNotifications(nil) + + // Subscribe to notifications. + sub, unsubscribe := notifs.Sub() + + var wg sync.WaitGroup + wg.Add(1) + + receivedNotifications := make([]Notification, 0) + + // Goroutine to listen for notifications. + go func() { + defer wg.Done() + for notification := range sub { + receivedNotifications = append(receivedNotifications, notification) + } + }() + + // Add notifications. + notifs.AddNotification("Test Notification 1") + notifs.AddNotification("Test Notification 2") + + // Modify a notification. + notifs.AddNotification("Test Notification 1") + + // Delete a notification. + notifs.DeleteNotification("Test Notification 2") + + // Wait for notifications to propagate. + time.Sleep(100 * time.Millisecond) + + unsubscribe() + wg.Wait() // Wait for the subscriber goroutine to finish. + + // Verify that we received the expected number of notifications. + require.Len(t, receivedNotifications, 4, "Expected 4 notifications (2 active, 1 modified, 1 deleted).") + + // Check the content and state of received notifications. + expected := []struct { + Text string + Active bool + }{ + {"Test Notification 1", true}, + {"Test Notification 2", true}, + {"Test Notification 1", true}, + {"Test Notification 2", false}, + } + + for i, n := range receivedNotifications { + require.Equal(t, expected[i].Text, n.Text, "Notification text mismatch at index %d.", i) + require.Equal(t, expected[i].Active, n.Active, "Notification active state mismatch at index %d.", i) + } +} + +// TestMultipleSubscribers tests that multiple subscribers receive notifications independently. +func TestMultipleSubscribers(t *testing.T) { + notifs := NewNotifications(nil) + + // Subscribe two subscribers to notifications. + sub1, unsubscribe1 := notifs.Sub() + + sub2, unsubscribe2 := notifs.Sub() + + var wg sync.WaitGroup + wg.Add(2) + + receivedSub1 := make([]Notification, 0) + receivedSub2 := make([]Notification, 0) + + // Goroutine for subscriber 1. + go func() { + defer wg.Done() + for notification := range sub1 { + receivedSub1 = append(receivedSub1, notification) + } + }() + + // Goroutine for subscriber 2. + go func() { + defer wg.Done() + for notification := range sub2 { + receivedSub2 = append(receivedSub2, notification) + } + }() + + // Add and delete notifications. + notifs.AddNotification("Test Notification 1") + notifs.DeleteNotification("Test Notification 1") + + // Wait for notifications to propagate. + time.Sleep(100 * time.Millisecond) + + // Unsubscribe both. + unsubscribe1() + unsubscribe2() + + wg.Wait() + + // Both subscribers should have received the same 2 notifications. + require.Len(t, receivedSub1, 2, "Expected 2 notifications for subscriber 1.") + require.Len(t, receivedSub2, 2, "Expected 2 notifications for subscriber 2.") + + // Verify that both subscribers received the same notifications. + for i := 0; i < 2; i++ { + require.Equal(t, receivedSub1[i], receivedSub2[i], "Subscriber notification mismatch at index %d.", i) + } +} + +// TestUnsubscribe tests that unsubscribing prevents further notifications from being received. +func TestUnsubscribe(t *testing.T) { + notifs := NewNotifications(nil) + + // Subscribe to notifications. + sub, unsubscribe := notifs.Sub() + + var wg sync.WaitGroup + wg.Add(1) + + receivedNotifications := make([]Notification, 0) + + // Goroutine to listen for notifications. + go func() { + defer wg.Done() + for notification := range sub { + receivedNotifications = append(receivedNotifications, notification) + } + }() + + // Add a notification and then unsubscribe. + notifs.AddNotification("Test Notification 1") + time.Sleep(100 * time.Millisecond) // Allow time for notification delivery. + unsubscribe() // Unsubscribe. + + // Add another notification after unsubscribing. + notifs.AddNotification("Test Notification 2") + + // Wait for the subscriber goroutine to finish. + wg.Wait() + + // Only the first notification should have been received. + require.Len(t, receivedNotifications, 1, "Expected 1 notification before unsubscribe.") + require.Equal(t, "Test Notification 1", receivedNotifications[0].Text, "Unexpected notification text.") +} diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 0ec8467faa..5eadbdbe75 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -15,6 +15,7 @@ package v1 import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -54,6 +55,7 @@ import ( "github.com/prometheus/prometheus/util/annotations" "github.com/prometheus/prometheus/util/httputil" "github.com/prometheus/prometheus/util/stats" + "github.com/prometheus/prometheus/web/api" ) type status string @@ -202,16 +204,18 @@ type API struct { ready func(http.HandlerFunc) http.HandlerFunc globalURLOptions GlobalURLOptions - db TSDBAdminStats - dbDir string - enableAdmin bool - logger log.Logger - CORSOrigin *regexp.Regexp - buildInfo *PrometheusVersion - runtimeInfo func() (RuntimeInfo, error) - gatherer prometheus.Gatherer - isAgent bool - statsRenderer StatsRenderer + db TSDBAdminStats + dbDir string + enableAdmin bool + logger log.Logger + CORSOrigin *regexp.Regexp + buildInfo *PrometheusVersion + runtimeInfo func() (RuntimeInfo, error) + gatherer prometheus.Gatherer + isAgent bool + statsRenderer StatsRenderer + notificationsGetter func() []api.Notification + notificationsSub func() (<-chan api.Notification, func()) remoteWriteHandler http.Handler remoteReadHandler http.Handler @@ -245,6 +249,8 @@ func NewAPI( corsOrigin *regexp.Regexp, runtimeInfo func() (RuntimeInfo, error), buildInfo *PrometheusVersion, + notificationsGetter func() []api.Notification, + notificationsSub func() (<-chan api.Notification, func()), gatherer prometheus.Gatherer, registerer prometheus.Registerer, statsRenderer StatsRenderer, @@ -261,22 +267,24 @@ func NewAPI( targetRetriever: tr, alertmanagerRetriever: ar, - now: time.Now, - config: configFunc, - flagsMap: flagsMap, - ready: readyFunc, - globalURLOptions: globalURLOptions, - db: db, - dbDir: dbDir, - enableAdmin: enableAdmin, - rulesRetriever: rr, - logger: logger, - CORSOrigin: corsOrigin, - runtimeInfo: runtimeInfo, - buildInfo: buildInfo, - gatherer: gatherer, - isAgent: isAgent, - statsRenderer: DefaultStatsRenderer, + now: time.Now, + config: configFunc, + flagsMap: flagsMap, + ready: readyFunc, + globalURLOptions: globalURLOptions, + db: db, + dbDir: dbDir, + enableAdmin: enableAdmin, + rulesRetriever: rr, + logger: logger, + CORSOrigin: corsOrigin, + runtimeInfo: runtimeInfo, + buildInfo: buildInfo, + gatherer: gatherer, + isAgent: isAgent, + statsRenderer: DefaultStatsRenderer, + notificationsGetter: notificationsGetter, + notificationsSub: notificationsSub, remoteReadHandler: remote.NewReadHandler(logger, registerer, q, configFunc, remoteReadSampleLimit, remoteReadConcurrencyLimit, remoteReadMaxBytesInFrame), } @@ -390,6 +398,8 @@ func (api *API) Register(r *route.Router) { r.Get("/status/flags", wrap(api.serveFlags)) r.Get("/status/tsdb", wrapAgent(api.serveTSDBStatus)) r.Get("/status/walreplay", api.serveWALReplayStatus) + r.Get("/notifications", api.notifications) + r.Get("/notifications/live", api.notificationsSSE) r.Post("/read", api.ready(api.remoteRead)) r.Post("/write", api.ready(api.remoteWrite)) r.Post("/otlp/v1/metrics", api.ready(api.otlpWrite)) @@ -1668,6 +1678,49 @@ func (api *API) serveWALReplayStatus(w http.ResponseWriter, r *http.Request) { }, nil, "") } +func (api *API) notifications(w http.ResponseWriter, r *http.Request) { + httputil.SetCORS(w, api.CORSOrigin, r) + api.respond(w, r, api.notificationsGetter(), nil, "") +} + +func (api *API) notificationsSSE(w http.ResponseWriter, r *http.Request) { + httputil.SetCORS(w, api.CORSOrigin, r) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Subscribe to notifications. + notifications, unsubscribe := api.notificationsSub() + defer unsubscribe() + + // Set up a flusher to push the response to the client. + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + for { + select { + case notification := <-notifications: + // Marshal the notification to JSON. + jsonData, err := json.Marshal(notification) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + continue + } + + // Write the event data in SSE format with JSON content. + fmt.Fprintf(w, "data: %s\n\n", jsonData) + + // Flush the response to ensure the data is sent immediately. + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + func (api *API) remoteRead(w http.ResponseWriter, r *http.Request) { // This is only really for tests - this will never be nil IRL. if api.remoteReadHandler != nil { diff --git a/web/api/v1/errors_test.go b/web/api/v1/errors_test.go index 7e1fc09d8a..db16b9fb3b 100644 --- a/web/api/v1/errors_test.go +++ b/web/api/v1/errors_test.go @@ -134,6 +134,8 @@ func createPrometheusAPI(t *testing.T, q storage.SampleAndChunkQueryable) *route regexp.MustCompile(".*"), func() (RuntimeInfo, error) { return RuntimeInfo{}, errors.New("not implemented") }, &PrometheusVersion{}, + nil, + nil, prometheus.DefaultGatherer, nil, nil, diff --git a/web/ui/mantine-ui/src/App.tsx b/web/ui/mantine-ui/src/App.tsx index aa5eb3714f..3e3466825c 100644 --- a/web/ui/mantine-ui/src/App.tsx +++ b/web/ui/mantine-ui/src/App.tsx @@ -64,6 +64,8 @@ import { useAppDispatch } from "./state/hooks"; import { updateSettings, useSettings } from "./state/settingsSlice"; import SettingsMenu from "./components/SettingsMenu"; import ReadinessWrapper from "./components/ReadinessWrapper"; +import NotificationsProvider from "./components/NotificationsProvider"; +import NotificationsIcon from "./components/NotificationsIcon"; import { QueryParamProvider } from "use-query-params"; import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6"; import ServiceDiscoveryPage from "./pages/service-discovery/ServiceDiscoveryPage"; @@ -314,6 +316,7 @@ function App() { const navActionIcons = ( <> + - - - - - - - - Prometheus{agentMode && " Agent"} + + + + + + + + + Prometheus{agentMode && " Agent"} + + + + {navLinks} - - - {navLinks} + + + {navActionIcons} - - {navActionIcons} - + - - - + - - {navLinks} - - {navActionIcons} - - + + {navLinks} + + {navActionIcons} + + + diff --git a/web/ui/mantine-ui/src/api/api.ts b/web/ui/mantine-ui/src/api/api.ts index d7446d6896..f1dd2b8c0c 100644 --- a/web/ui/mantine-ui/src/api/api.ts +++ b/web/ui/mantine-ui/src/api/api.ts @@ -93,6 +93,7 @@ type QueryOptions = { path: string; params?: Record; enabled?: boolean; + refetchInterval?: false | number; recordResponseTime?: (time: number) => void; }; @@ -102,6 +103,7 @@ export const useAPIQuery = ({ params, enabled, recordResponseTime, + refetchInterval, }: QueryOptions) => { const { pathPrefix } = useSettings(); @@ -109,6 +111,7 @@ export const useAPIQuery = ({ queryKey: key !== undefined ? key : [path, params], retry: false, refetchOnWindowFocus: false, + refetchInterval: refetchInterval, gcTime: 0, enabled, queryFn: createQueryFn({ pathPrefix, path, params, recordResponseTime }), diff --git a/web/ui/mantine-ui/src/api/responseTypes/notifications.ts b/web/ui/mantine-ui/src/api/responseTypes/notifications.ts new file mode 100644 index 0000000000..d6ebf68d41 --- /dev/null +++ b/web/ui/mantine-ui/src/api/responseTypes/notifications.ts @@ -0,0 +1,8 @@ +export interface Notification { + text: string; + date: string; + active: boolean; + modified: boolean; +} + +export type NotificationsResult = Notification[]; diff --git a/web/ui/mantine-ui/src/components/NotificationsIcon.tsx b/web/ui/mantine-ui/src/components/NotificationsIcon.tsx new file mode 100644 index 0000000000..5ab28b037a --- /dev/null +++ b/web/ui/mantine-ui/src/components/NotificationsIcon.tsx @@ -0,0 +1,62 @@ +import { ActionIcon, Indicator, Popover, Card, Text, Stack, ScrollArea, Group } from "@mantine/core"; +import { IconBell, IconAlertTriangle, IconNetworkOff } from "@tabler/icons-react"; +import { useNotifications } from '../state/useNotifications'; +import { actionIconStyle } from "../styles"; +import { useSettings } from '../state/settingsSlice'; +import { formatTimestamp } from "../lib/formatTime"; + +const NotificationsIcon = () => { + const { notifications, isConnectionError } = useNotifications(); + const { useLocalTime } = useSettings(); + + return ( + (notifications.length === 0 && !isConnectionError) ? null : ( + + + + + + + + + + + Notifications + + { isConnectionError ? ( + + + + + Real-time notifications interrupted. + Please refresh the page or check your connection. + + + + ) : notifications.length === 0 ? ( + No notifications + ) : (notifications.map((notification, index) => ( + + + + + {notification.text} + {formatTimestamp(new Date(notification.date).valueOf() / 1000, useLocalTime)} + + + + )))} + + + + + + ) + ); +}; + +export default NotificationsIcon; diff --git a/web/ui/mantine-ui/src/components/NotificationsProvider.tsx b/web/ui/mantine-ui/src/components/NotificationsProvider.tsx new file mode 100644 index 0000000000..73de54131e --- /dev/null +++ b/web/ui/mantine-ui/src/components/NotificationsProvider.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { useSettings } from '../state/settingsSlice'; +import { NotificationsContext } from '../state/useNotifications'; +import { Notification, NotificationsResult } from "../api/responseTypes/notifications"; +import { useAPIQuery } from '../api/api'; + +export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { pathPrefix } = useSettings(); + const [notifications, setNotifications] = useState([]); + const [isConnectionError, setIsConnectionError] = useState(false); + const [shouldFetchFromAPI, setShouldFetchFromAPI] = useState(false); + + const { data, isError } = useAPIQuery({ + path: '/notifications', + enabled: shouldFetchFromAPI, + refetchInterval: 10000, + }); + + useEffect(() => { + if (data && data.data) { + setNotifications(data.data); + } + setIsConnectionError(isError); + }, [data, isError]); + + useEffect(() => { + const eventSource = new EventSource(`${pathPrefix}/api/v1/notifications/live`); + + eventSource.onmessage = (event) => { + const notification: Notification = JSON.parse(event.data); + + setNotifications((prev: Notification[]) => { + const updatedNotifications = [...prev.filter((n: Notification) => n.text !== notification.text)]; + + if (notification.active) { + updatedNotifications.push(notification); + } + + return updatedNotifications; + }); + }; + + eventSource.onerror = () => { + eventSource.close(); + setIsConnectionError(true); + setShouldFetchFromAPI(true); + }; + + return () => { + eventSource.close(); + }; + }, [pathPrefix]); + + return ( + + {children} + + ); +}; + +export default NotificationsProvider; diff --git a/web/ui/mantine-ui/src/state/useNotifications.ts b/web/ui/mantine-ui/src/state/useNotifications.ts new file mode 100644 index 0000000000..40a3f09206 --- /dev/null +++ b/web/ui/mantine-ui/src/state/useNotifications.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; +import { Notification } from "../api/responseTypes/notifications"; + +export type NotificationsContextType = { + notifications: Notification[]; + isConnectionError: boolean; +}; + +const defaultContextValue: NotificationsContextType = { + notifications: [], + isConnectionError: false, +}; + +export const NotificationsContext = createContext(defaultContextValue); + +// Custom hook to access notifications context +export const useNotifications = () => useContext(NotificationsContext); diff --git a/web/web.go b/web/web.go index 6b0d9cd187..87e4164c58 100644 --- a/web/web.go +++ b/web/web.go @@ -59,6 +59,7 @@ import ( "github.com/prometheus/prometheus/template" "github.com/prometheus/prometheus/util/httputil" "github.com/prometheus/prometheus/util/netconnlimit" + "github.com/prometheus/prometheus/web/api" api_v1 "github.com/prometheus/prometheus/web/api/v1" "github.com/prometheus/prometheus/web/ui" ) @@ -266,6 +267,8 @@ type Options struct { RuleManager *rules.Manager Notifier *notifier.Manager Version *PrometheusVersion + NotificationsGetter func() []api.Notification + NotificationsSub func() (<-chan api.Notification, func()) Flags map[string]string ListenAddresses []string @@ -376,6 +379,8 @@ func New(logger log.Logger, o *Options) *Handler { h.options.CORSOrigin, h.runtimeInfo, h.versionInfo, + h.options.NotificationsGetter, + h.options.NotificationsSub, o.Gatherer, o.Registerer, nil,