mirror of
https://github.com/TwiN/gatus.git
synced 2026-02-15 15:00:04 +00:00
feat: Implement announcements (#1204)
* feat: Implement announcements Fixes #1203 * Remove unnecessary code * Fix new announcement test * Update web/app/src/views/Home.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove useless garbage * Require announcement timestamp --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
94
README.md
94
README.md
@@ -78,6 +78,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
||||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||||
- [Setting a default alert](#setting-a-default-alert)
|
- [Setting a default alert](#setting-a-default-alert)
|
||||||
|
- [Announcements](#announcements)
|
||||||
- [Maintenance](#maintenance)
|
- [Maintenance](#maintenance)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Basic Authentication](#basic-authentication)
|
- [Basic Authentication](#basic-authentication)
|
||||||
@@ -222,36 +223,37 @@ If you want to test it locally, see [Docker](#docker).
|
|||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-----------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||||
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
| `announcements` | [Announcements configuration](#announcements). | `[]` |
|
||||||
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
|
||||||
| `security` | [Security configuration](#security). | `{}` |
|
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
|
||||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
| `security` | [Security configuration](#security). | `{}` |
|
||||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||||
| `web` | Web configuration. | `{}` |
|
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
| `web` | Web configuration. | `{}` |
|
||||||
| `web.port` | Port to listen on. | `8080` |
|
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
| `web.port` | Port to listen on. | `8080` |
|
||||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
||||||
| `ui` | UI configuration. | `{}` |
|
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
||||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
| `ui` | UI configuration. | `{}` |
|
||||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||||
| `ui.logo` | URL to the logo to display. | `""` |
|
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
||||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
| `ui.logo` | URL to the logo to display. | `""` |
|
||||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||||
| `ui.custom-css` | Custom CSS | `""` |
|
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
| `ui.custom-css` | Custom CSS | `""` |
|
||||||
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||||
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
||||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
||||||
|
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||||
|
|
||||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||||
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
|
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
|
||||||
@@ -400,6 +402,38 @@ Here are some examples of conditions you can use:
|
|||||||
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||||
|
|
||||||
|
|
||||||
|
### Announcements
|
||||||
|
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
|
||||||
|
| `announcements` | List of announcements to display | `[]` |
|
||||||
|
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
|
||||||
|
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
|
||||||
|
| `announcements[].message` | The message to display to users | Required |
|
||||||
|
|
||||||
|
Types:
|
||||||
|
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||||
|
- **warning**: Indicates potential issues or important notices (yellow theme)
|
||||||
|
- **information**: General information or updates (blue theme)
|
||||||
|
- **operational**: Indicates resolved issues or normal operations (green theme)
|
||||||
|
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
|
||||||
|
|
||||||
|
Example Configuration:
|
||||||
|
```yaml
|
||||||
|
announcements:
|
||||||
|
- timestamp: 2025-08-15T14:00:00Z
|
||||||
|
type: outage
|
||||||
|
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
||||||
|
- timestamp: 2025-08-15T16:15:00Z
|
||||||
|
type: operational
|
||||||
|
message: "Database maintenance completed successfully. All systems operational."
|
||||||
|
- timestamp: 2025-08-15T12:00:00Z
|
||||||
|
type: information
|
||||||
|
message: "New monitoring dashboard features will be deployed next week"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
|||||||
// UNPROTECTED ROUTES //
|
// UNPROTECTED ROUTES //
|
||||||
////////////////////////
|
////////////////////////
|
||||||
unprotectedAPIRouter := apiRouter.Group("/")
|
unprotectedAPIRouter := apiRouter.Group("/")
|
||||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
"github.com/TwiN/gatus/v5/security"
|
"github.com/TwiN/gatus/v5/security"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigHandler struct {
|
type ConfigHandler struct {
|
||||||
securityConfig *security.Config
|
securityConfig *security.Config
|
||||||
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||||
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
|||||||
hasOIDC = handler.securityConfig.OIDC != nil
|
hasOIDC = handler.securityConfig.OIDC != nil
|
||||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||||
}
|
}
|
||||||
// Return the config
|
|
||||||
|
// Prepare response with announcements
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"oidc": hasOIDC,
|
||||||
|
"authenticated": isAuthenticated,
|
||||||
|
}
|
||||||
|
// Add announcements if available, otherwise use empty slice
|
||||||
|
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
|
||||||
|
response["announcements"] = handler.config.Announcements
|
||||||
|
} else {
|
||||||
|
response["announcements"] = []interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the config as JSON
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
return c.Status(200).
|
responseBytes, err := json.Marshal(response)
|
||||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
if err != nil {
|
||||||
|
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
|
||||||
|
}
|
||||||
|
return c.Status(200).Send(responseBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("expected err to be nil, but was", err)
|
t.Error("expected err to be nil, but was", err)
|
||||||
}
|
}
|
||||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
|
||||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
config/announcement/announcement.go
Normal file
94
config/announcement/announcement.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package announcement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TypeOutage represents a service outage
|
||||||
|
TypeOutage = "outage"
|
||||||
|
|
||||||
|
// TypeWarning represents a warning or potential issue
|
||||||
|
TypeWarning = "warning"
|
||||||
|
|
||||||
|
// TypeInformation represents general information
|
||||||
|
TypeInformation = "information"
|
||||||
|
|
||||||
|
// TypeOperational represents operational status or resolved issues
|
||||||
|
TypeOperational = "operational"
|
||||||
|
|
||||||
|
// TypeNone represents no specific type (default)
|
||||||
|
TypeNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
|
||||||
|
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
|
||||||
|
|
||||||
|
// ErrEmptyMessage is returned when an announcement has an empty message
|
||||||
|
ErrEmptyMessage = errors.New("announcement message cannot be empty")
|
||||||
|
|
||||||
|
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
|
||||||
|
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
|
||||||
|
|
||||||
|
// validTypes contains all valid announcement types
|
||||||
|
validTypes = map[string]bool{
|
||||||
|
TypeOutage: true,
|
||||||
|
TypeWarning: true,
|
||||||
|
TypeInformation: true,
|
||||||
|
TypeOperational: true,
|
||||||
|
TypeNone: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Announcement represents a system-wide announcement
|
||||||
|
type Announcement struct {
|
||||||
|
// Timestamp is the UTC timestamp when the announcement was made
|
||||||
|
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
|
||||||
|
|
||||||
|
// Type is the type of announcement (outage, warning, information, operational, none)
|
||||||
|
Type string `yaml:"type" json:"type"`
|
||||||
|
|
||||||
|
// Message is the user-facing text describing the announcement
|
||||||
|
Message string `yaml:"message" json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||||
|
func (a *Announcement) ValidateAndSetDefaults() error {
|
||||||
|
// Validate message
|
||||||
|
if a.Message == "" {
|
||||||
|
return ErrEmptyMessage
|
||||||
|
}
|
||||||
|
// Set default type if empty
|
||||||
|
if a.Type == "" {
|
||||||
|
a.Type = TypeNone
|
||||||
|
}
|
||||||
|
// Validate type
|
||||||
|
if !validTypes[a.Type] {
|
||||||
|
return ErrInvalidAnnouncementType
|
||||||
|
}
|
||||||
|
// If timestamp is zero, return an error
|
||||||
|
if a.Timestamp.IsZero() {
|
||||||
|
return ErrMissingTimestamp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
|
||||||
|
func SortByTimestamp(announcements []*Announcement) {
|
||||||
|
sort.Slice(announcements, func(i, j int) bool {
|
||||||
|
return announcements[i].Timestamp.After(announcements[j].Timestamp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
|
||||||
|
func ValidateAndSetDefaults(announcements []*Announcement) error {
|
||||||
|
for _, announcement := range announcements {
|
||||||
|
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting"
|
"github.com/TwiN/gatus/v5/alerting"
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
|
"github.com/TwiN/gatus/v5/config/announcement"
|
||||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||||
@@ -99,6 +100,9 @@ type Config struct {
|
|||||||
// Connectivity is the configuration for connectivity
|
// Connectivity is the configuration for connectivity
|
||||||
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
||||||
|
|
||||||
|
// Announcements is the list of system-wide announcements
|
||||||
|
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
|
||||||
|
|
||||||
configPath string // path to the file or directory from which config was loaded
|
configPath string // path to the file or directory from which config was loaded
|
||||||
lastFileModTime time.Time // last modification time
|
lastFileModTime time.Time // last modification time
|
||||||
}
|
}
|
||||||
@@ -302,6 +306,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
if err := validateConnectivityConfig(config); err != nil {
|
if err := validateConnectivityConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validateAnnouncementsConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Cross-config changes
|
// Cross-config changes
|
||||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||||
}
|
}
|
||||||
@@ -315,6 +322,17 @@ func validateConnectivityConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAnnouncementsConfig(config *Config) error {
|
||||||
|
if config.Announcements != nil {
|
||||||
|
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Sort announcements by timestamp (newest first) for API response
|
||||||
|
announcement.SortByTimestamp(config.Announcements)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateRemoteConfig(config *Config) error {
|
func validateRemoteConfig(config *Config) error {
|
||||||
if config.Remote != nil {
|
if config.Remote != nil {
|
||||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="relative">
|
<main class="relative">
|
||||||
<router-view @showTooltip="showTooltip" />
|
<router-view @showTooltip="showTooltip" :announcements="announcements" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Menu, X, LogIn } from 'lucide-vue-next'
|
import { Menu, X, LogIn } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -169,9 +169,11 @@ const route = useRoute()
|
|||||||
// State
|
// State
|
||||||
const retrievedConfig = ref(false)
|
const retrievedConfig = ref(false)
|
||||||
const config = ref({ oidc: false, authenticated: true })
|
const config = ref({ oidc: false, authenticated: true })
|
||||||
|
const announcements = ref([])
|
||||||
const tooltip = ref({})
|
const tooltip = ref({})
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const isOidcLoading = ref(false)
|
const isOidcLoading = ref(false)
|
||||||
|
let configInterval = null
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const logo = computed(() => {
|
const logo = computed(() => {
|
||||||
@@ -199,6 +201,7 @@ const fetchConfig = async () => {
|
|||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
config.value = data
|
config.value = data
|
||||||
|
announcements.value = data.announcements || []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch config:', error)
|
console.error('Failed to fetch config:', error)
|
||||||
@@ -210,8 +213,18 @@ const showTooltip = (result, event) => {
|
|||||||
tooltip.value = { result, event }
|
tooltip.value = { result, event }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch config on mount
|
// Fetch config on mount and set up interval
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
|
// Refresh config every 10 minutes for announcements
|
||||||
|
configInterval = setInterval(fetchConfig, 600000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up interval on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (configInterval) {
|
||||||
|
clearInterval(configInterval)
|
||||||
|
configInterval = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
294
web/app/src/components/AnnouncementBanner.vue
Normal file
294
web/app/src/components/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="announcements && announcements.length" class="announcement-container mb-4">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200',
|
||||||
|
containerClasses
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors',
|
||||||
|
isCollapsed ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-600'
|
||||||
|
]"
|
||||||
|
@click="toggleCollapsed"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<component :is="mostRecentIcon" :class="['w-5 h-5', mostRecentIconClass]" />
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Announcements</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
({{ announcements.length }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
:class="[
|
||||||
|
'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200',
|
||||||
|
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline Content -->
|
||||||
|
<div
|
||||||
|
v-if="!isCollapsed"
|
||||||
|
class="p-4 transition-all duration-200 rounded-b-lg"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Announcements -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(group, date) in groupedAnnouncements"
|
||||||
|
:key="date"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<!-- Vertical line from date to last icon -->
|
||||||
|
<div
|
||||||
|
v-if="group.length > 0"
|
||||||
|
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||||
|
:style="getTimelineHeight(group)"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Date Header -->
|
||||||
|
<div class="flex items-center gap-3 mb-2 relative">
|
||||||
|
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
|
||||||
|
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{{ formatDate(date) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 border-t border-gray-200 dark:border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Announcements for this date -->
|
||||||
|
<div class="space-y-2 ml-7 relative">
|
||||||
|
<div
|
||||||
|
v-for="(announcement, index) in group"
|
||||||
|
:key="`${date}-${index}-${announcement.timestamp}`"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<!-- Timeline Icon -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
|
||||||
|
getTypeClasses(announcement.type).border
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="getTypeIcon(announcement.type)"
|
||||||
|
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Announcement Card -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
|
||||||
|
getTypeClasses(announcement.type).background
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
:class="[
|
||||||
|
'text-xs font-mono whitespace-nowrap',
|
||||||
|
getTypeClasses(announcement.type).text
|
||||||
|
]"
|
||||||
|
:title="formatFullTimestamp(announcement.timestamp)"
|
||||||
|
>
|
||||||
|
{{ formatTime(announcement.timestamp) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
announcements: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collapse state
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type configurations
|
||||||
|
const typeConfigs = {
|
||||||
|
outage: {
|
||||||
|
icon: XCircle,
|
||||||
|
background: 'bg-red-50 border-gray-200 dark:bg-red-900/50 dark:border-gray-600',
|
||||||
|
border: 'border-red-500',
|
||||||
|
iconColor: 'text-red-600 dark:text-red-400',
|
||||||
|
text: 'text-red-700 dark:text-red-300'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
background: 'bg-yellow-50 border-gray-200 dark:bg-yellow-900/50 dark:border-gray-600',
|
||||||
|
border: 'border-yellow-500',
|
||||||
|
iconColor: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
text: 'text-yellow-700 dark:text-yellow-300'
|
||||||
|
},
|
||||||
|
information: {
|
||||||
|
icon: Info,
|
||||||
|
background: 'bg-blue-50 border-gray-200 dark:bg-blue-900/50 dark:border-gray-600',
|
||||||
|
border: 'border-blue-500',
|
||||||
|
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||||
|
text: 'text-blue-700 dark:text-blue-300'
|
||||||
|
},
|
||||||
|
operational: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
background: 'bg-green-50 border-gray-200 dark:bg-green-900/50 dark:border-gray-600',
|
||||||
|
border: 'border-green-500',
|
||||||
|
iconColor: 'text-green-600 dark:text-green-400',
|
||||||
|
text: 'text-green-700 dark:text-green-300'
|
||||||
|
},
|
||||||
|
none: {
|
||||||
|
icon: Circle,
|
||||||
|
background: 'bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-600',
|
||||||
|
border: 'border-gray-500',
|
||||||
|
iconColor: 'text-gray-600 dark:text-gray-400',
|
||||||
|
text: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const mostRecentAnnouncement = computed(() => {
|
||||||
|
return props.announcements && props.announcements.length > 0 ? props.announcements[0] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostRecentIcon = computed(() => {
|
||||||
|
const type = mostRecentAnnouncement.value?.type || 'none'
|
||||||
|
return typeConfigs[type]?.icon || Circle
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostRecentIconClass = computed(() => {
|
||||||
|
const type = mostRecentAnnouncement.value?.type || 'none'
|
||||||
|
return typeConfigs[type]?.iconColor || 'text-gray-600 dark:text-gray-400'
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerClasses = computed(() => {
|
||||||
|
const type = mostRecentAnnouncement.value?.type || 'none'
|
||||||
|
const config = typeConfigs[type]
|
||||||
|
// Add a subtle left border accent to indicate announcement type
|
||||||
|
return `border-l-4 ${config.border.replace('border-', 'border-l-')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedAnnouncements = computed(() => {
|
||||||
|
if (!props.announcements || props.announcements.length === 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = {}
|
||||||
|
props.announcements.forEach(announcement => {
|
||||||
|
const date = new Date(announcement.timestamp).toDateString()
|
||||||
|
if (!groups[date]) {
|
||||||
|
groups[date] = []
|
||||||
|
}
|
||||||
|
groups[date].push(announcement)
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const getTypeIcon = (type) => {
|
||||||
|
return typeConfigs[type]?.icon || Circle
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeClasses = (type) => {
|
||||||
|
return typeConfigs[type] || typeConfigs.none
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimelineHeight = (group) => {
|
||||||
|
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
|
||||||
|
return {
|
||||||
|
top: '1.5rem',
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Today'
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Yesterday'
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFullTimestamp = (timestamp) => {
|
||||||
|
return new Date(timestamp).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.announcement-container {
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.announcement-container .ml-7 {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -33,6 +33,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Announcements Banner -->
|
||||||
|
<AnnouncementBanner :announcements="props.announcements" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||||
<Loading size="lg" />
|
<Loading size="lg" />
|
||||||
</div>
|
</div>
|
||||||
@@ -145,8 +150,16 @@ import EndpointCard from '@/components/EndpointCard.vue'
|
|||||||
import SearchBar from '@/components/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar.vue'
|
||||||
import Settings from '@/components/Settings.vue'
|
import Settings from '@/components/Settings.vue'
|
||||||
import Loading from '@/components/Loading.vue'
|
import Loading from '@/components/Loading.vue'
|
||||||
|
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
|
||||||
import { SERVER_URL } from '@/main.js'
|
import { SERVER_URL } from '@/main.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
announcements: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['showTooltip'])
|
const emit = defineEmits(['showTooltip'])
|
||||||
|
|
||||||
const endpointStatuses = ref([])
|
const endpointStatuses = ref([])
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user