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:
TwiN
2025-08-16 09:54:50 -04:00
committed by GitHub
co-authored by Copilot
parent 609a634df3
commit 131447f702
12 changed files with 560 additions and 51 deletions
+64 -30
View File
@@ -78,6 +78,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Announcements](#announcements)
- [Maintenance](#maintenance)
- [Security](#security)
- [Basic Authentication](#basic-authentication)
@@ -222,36 +223,37 @@ If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `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-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `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). | `{}` |
| Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `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-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `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`.
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`.
### 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
| Parameter | Description | Default |
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
+1 -1
View File
@@ -75,7 +75,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
// UNPROTECTED ROUTES //
////////////////////////
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.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
+22 -3
View File
@@ -1,14 +1,17 @@
package api
import (
"encoding/json"
"fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
type ConfigHandler struct {
securityConfig *security.Config
config *config.Config
}
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
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")
return c.Status(200).
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
responseBytes, err := json.Marshal(response)
if err != nil {
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
}
return c.Status(200).Send(responseBytes)
}
+2 -2
View File
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != `{"oidc":true,"authenticated":false}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
}
}
+94
View 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
}
+18
View File
@@ -14,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"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/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
@@ -99,6 +100,9 @@ type Config struct {
// Connectivity is the configuration for connectivity
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
lastFileModTime time.Time // last modification time
}
@@ -302,6 +306,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
return nil, err
}
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
@@ -315,6 +322,17 @@ func validateConnectivityConfig(config *Config) error {
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 {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
+16 -3
View File
@@ -92,7 +92,7 @@
<!-- Main Content -->
<main class="relative">
<router-view @showTooltip="showTooltip" />
<router-view @showTooltip="showTooltip" :announcements="announcements" />
</main>
<!-- Footer -->
@@ -154,7 +154,7 @@
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, LogIn } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -169,9 +169,11 @@ const route = useRoute()
// State
const retrievedConfig = ref(false)
const config = ref({ oidc: false, authenticated: true })
const announcements = ref([])
const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
let configInterval = null
// Computed properties
const logo = computed(() => {
@@ -199,6 +201,7 @@ const fetchConfig = async () => {
if (response.status === 200) {
const data = await response.json()
config.value = data
announcements.value = data.announcements || []
}
} catch (error) {
console.error('Failed to fetch config:', error)
@@ -210,8 +213,18 @@ const showTooltip = (result, event) => {
tooltip.value = { result, event }
}
// Fetch config on mount
// Fetch config on mount and set up interval
onMounted(() => {
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>
@@ -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>
+13
View File
@@ -33,6 +33,11 @@
/>
</div>
<!-- Announcements Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<div>
</div>
<div v-if="loading" class="flex items-center justify-center py-20">
<Loading size="lg" />
</div>
@@ -145,8 +150,16 @@ import EndpointCard from '@/components/EndpointCard.vue'
import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import { SERVER_URL } from '@/main.js'
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['showTooltip'])
const endpointStatuses = ref([])
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long