1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-04 10:06:44 +00:00

feat(announcements): Add support for archived announcements and add past announcement section in UI (#1382)

* feat(announcements): Add support for archived announcements and add past announcement section in UI

* Add missing field
This commit is contained in:
TwiN
2025-11-07 19:35:39 -05:00
committed by GitHub
parent 9e97efaba1
commit 607f3c5549
9 changed files with 501 additions and 22 deletions

View File

@@ -528,12 +528,15 @@ Here are some examples of conditions you can use:
### 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 |
This is essentially what some status page calls "incident communications".
| 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 |
| `announcements[].archived` | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false` |
Types:
- **outage**: Indicates service disruptions or critical issues (red theme)
@@ -545,17 +548,24 @@ Types:
Example Configuration:
```yaml
announcements:
- timestamp: 2025-08-15T14:00:00Z
- timestamp: 2025-11-07T14:00:00Z
type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-08-15T16:15:00Z
- timestamp: 2025-11-07T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z
- timestamp: 2025-11-07T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
- timestamp: 2025-11-06T09:00:00Z
type: warning
message: "Elevated API response times observed for US customers"
archived: true
```
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
![Gatus past announcements section](.github/assets/past-announcements.jpg)
### Storage
| Parameter | Description | Default |

View File

@@ -53,6 +53,10 @@ type Announcement struct {
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
// Archived indicates whether the announcement should be displayed in the historical section
// instead of at the top of the status page
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary

View File

@@ -0,0 +1,241 @@
package announcement
import (
"errors"
"testing"
"time"
)
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcement *Announcement
expectedError error
expectedType string
}{
{
name: "valid-announcement-with-all-fields",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "This is a test announcement",
Archived: false,
},
expectedError: nil,
expectedType: TypeWarning,
},
{
name: "valid-announcement-with-archived-true",
announcement: &Announcement{
Timestamp: now,
Type: TypeOperational,
Message: "This is an archived announcement",
Archived: true,
},
expectedError: nil,
expectedType: TypeOperational,
},
{
name: "valid-announcement-with-empty-type-should-default-to-none",
announcement: &Announcement{
Timestamp: now,
Message: "This announcement has no type",
},
expectedError: nil,
expectedType: TypeNone,
},
{
name: "invalid-announcement-with-empty-message",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "",
},
expectedError: ErrEmptyMessage,
},
{
name: "invalid-announcement-with-zero-timestamp",
announcement: &Announcement{
Timestamp: time.Time{},
Type: TypeWarning,
Message: "Test message",
},
expectedError: ErrMissingTimestamp,
},
{
name: "invalid-announcement-with-invalid-type",
announcement: &Announcement{
Timestamp: now,
Type: "invalid-type",
Message: "Test message",
},
expectedError: ErrInvalidAnnouncementType,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.announcement.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
}
})
}
}
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
now := time.Now()
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
for _, validType := range validTypes {
t.Run("type-"+validType, func(t *testing.T) {
announcement := &Announcement{
Timestamp: now,
Type: validType,
Message: "Test message",
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error for type %s, got %v", validType, err)
}
if announcement.Type != validType {
t.Errorf("expected type %s, got %s", validType, announcement.Type)
}
})
}
}
func TestSortByTimestamp(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now"},
{Timestamp: later, Message: "later"},
{Timestamp: earlier, Message: "earlier"},
}
SortByTimestamp(announcements)
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now", Archived: false},
{Timestamp: later, Message: "later", Archived: true},
{Timestamp: earlier, Message: "earlier", Archived: false},
}
SortByTimestamp(announcements)
// Sorting should be by timestamp only, not affected by archived status
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest, regardless of archived status")
}
if !announcements[0].Archived {
t.Error("expected first announcement to be archived")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestValidateAndSetDefaults_Slice(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcements []*Announcement
expectedError error
shouldValidate bool
}{
{
name: "all-valid-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "mixed-archived-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "one-invalid-announcement-in-slice",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: TypeOperational, Message: ""},
},
expectedError: ErrEmptyMessage,
shouldValidate: false,
},
{
name: "announcement-with-missing-timestamp",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
},
expectedError: ErrMissingTimestamp,
shouldValidate: false,
},
{
name: "announcement-with-invalid-type",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
},
expectedError: ErrInvalidAnnouncementType,
shouldValidate: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := ValidateAndSetDefaults(scenario.announcements)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
now := time.Now()
announcement := &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "Test announcement",
// Archived not set, should default to false
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error, got %v", err)
}
// Zero value for bool is false
if announcement.Archived {
t.Error("expected Archived to default to false")
}
}
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
announcements := []*Announcement{}
if err := ValidateAndSetDefaults(announcements); err != nil {
t.Errorf("expected no error for empty slice, got %v", err)
}
}

1
go.mod
View File

@@ -96,7 +96,6 @@ require (
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.66.10 // indirect

View File

@@ -47,7 +47,7 @@
<!-- 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">
<time class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ formatDate(date) }}
</time>
</div>
@@ -94,25 +94,25 @@
></div>
<!-- Announcement Card -->
<div
<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
<div class="flex items-center gap-3">
<time
:class="[
'text-xs font-mono whitespace-nowrap',
'text-sm font-mono whitespace-nowrap flex-shrink-0',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,210 @@
<template>
<div v-if="announcements && announcements.length" class="past-announcements">
<h2 class="text-2xl font-semibold text-foreground mb-6">Past Announcements</h2>
<div class="space-y-8">
<div
v-for="(group, date) in displayedAnnouncements"
:key="date"
>
<!-- Date Header -->
<div class="mb-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{{ formatDate(date) }}
</h3>
</div>
<!-- Announcements for this date or empty state -->
<div v-if="group.length > 0" class="space-y-3">
<div
v-for="(announcement, index) in group"
:key="`${date}-${index}-${announcement.timestamp}`"
:class="[
'border-l-4 p-4 transition-all duration-200',
getTypeClasses(announcement.type).background,
getTypeClasses(announcement.type).borderColor
]"
>
<div class="flex items-start gap-3">
<component
:is="getTypeIcon(announcement.type)"
:class="['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]"
/>
<time
:class="[
'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">
{{ announcement.message }}
</p>
</div>
</div>
</div>
</div>
<!-- Empty state for dates without announcements -->
<div v-else class="py-2">
<p class="text-sm italic text-muted-foreground/60">
No incidents reported on this day
</p>
</div>
</div>
<!-- View Older Announcements Link -->
<div v-if="hasOlderAnnouncements && !showAllAnnouncements">
<button @click="showAllAnnouncements = true" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group">
<ChevronDown class="w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200" />
<span class="group-hover:underline">View older announcements</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
// Props
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
// State
const showAllAnnouncements = ref(false)
// Type configurations (consistent with AnnouncementBanner)
const typeConfigs = {
outage: {
icon: XCircle,
background: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-500 dark:border-red-400',
iconColor: 'text-red-600 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
},
warning: {
icon: AlertTriangle,
background: 'bg-yellow-50 dark:bg-yellow-900/20',
borderColor: 'border-yellow-500 dark:border-yellow-400',
iconColor: 'text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-300'
},
information: {
icon: Info,
background: 'bg-blue-50 dark:bg-blue-900/20',
borderColor: 'border-blue-500 dark:border-blue-400',
iconColor: 'text-blue-600 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
operational: {
icon: CheckCircle,
background: 'bg-green-50 dark:bg-green-900/20',
borderColor: 'border-green-500 dark:border-green-400',
iconColor: 'text-green-600 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
none: {
icon: Circle,
background: 'bg-gray-50 dark:bg-gray-800/20',
borderColor: 'border-gray-500 dark:border-gray-400',
iconColor: 'text-gray-600 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
}
}
// Helper to normalize date to start of day
const normalizeDate = (date) => {
const normalized = new Date(date)
normalized.setHours(0, 0, 0, 0)
return normalized
}
// Computed properties
const displayedAnnouncements = computed(() => {
if (!props.announcements?.length) return {}
// Group announcements by date and find oldest
const grouped = {}
let oldest = new Date()
props.announcements.forEach(announcement => {
const date = new Date(announcement.timestamp)
const key = date.toDateString()
grouped[key] = grouped[key] || []
grouped[key].push(announcement)
if (date < oldest) oldest = date
})
// Calculate date range
const today = normalizeDate(new Date())
const endDate = showAllAnnouncements.value
? normalizeDate(oldest)
: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)
// Build result: today (if has announcements) + yesterday backwards
const result = {}
const todayKey = today.toDateString()
if (grouped[todayKey]) result[todayKey] = grouped[todayKey]
for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {
result[date.toDateString()] = grouped[date.toDateString()] || []
}
return result
})
// Check if there are announcements older than 14 days
const hasOlderAnnouncements = computed(() => {
if (!props.announcements?.length) return false
const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)
return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)
})
// Helper functions
const getTypeIcon = (type) => {
return typeConfigs[type]?.icon || Circle
}
const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const formatDate = (dateString) => {
const date = new Date(dateString)
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>

View File

@@ -22,8 +22,8 @@
</Button>
</div>
</div>
<!-- Announcement Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<!-- Announcement Banner (Active Announcements) -->
<AnnouncementBanner :announcements="activeAnnouncements" />
<!-- Search bar -->
<SearchBar
@search="handleSearch"
@@ -170,6 +170,11 @@
</Button>
</div>
</div>
<!-- Past Announcements Section -->
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
<PastAnnouncements :announcements="archivedAnnouncements" />
</div>
</div>
<Settings @refreshData="fetchData" />
@@ -187,6 +192,7 @@ 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 PastAnnouncements from '@/components/PastAnnouncements.vue'
import { SERVER_URL } from '@/main.js'
const props = defineProps({
@@ -196,6 +202,15 @@ const props = defineProps({
}
})
// Computed properties for active and archived announcements
const activeAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => !a.archived) : []
})
const archivedAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => a.archived) : []
})
const emit = defineEmits(['showTooltip'])
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