feat(site): enhance monitoring dashboard and public status pages

Implement incident tracking for public status pages, improve the monitoring
dashboard UI with better grouping and loading states, and refine domain
resolution logic.

- feat(hub): add incident support to public status pages
- feat(hub): implement immediate monitor checks on creation and resume
- feat(hub): improve domain status detection using DNS fallback when WHOIS fails
- feat(site): redesign monitoring dashboard with categorized cards
- feat(site): add incident detail view and management in the dashboard
- feat(site): add active incidents section to public status pages
- feat(site): add "Add System" functionality to systems table
- refactor(site): improve calendar view responsiveness and loading states
- style(site): add skeleton components for better UX during data fetching
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:22 +02:00
parent 7727be166b
commit c7e2c88604
15 changed files with 866 additions and 186 deletions
+48 -36
View File
@@ -4,18 +4,18 @@ import "time"
// StatusPage represents a public status page configuration
type StatusPage struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Logo string `json:"logo" db:"logo"`
Favicon string `json:"favicon" db:"favicon"`
Theme string `json:"theme" db:"theme"` // light, dark, auto
CustomCSS string `json:"custom_css" db:"custom_css"`
Public bool `json:"public" db:"public"`
ShowUptime bool `json:"show_uptime" db:"show_uptime"`
UserID string `json:"user" db:"user"`
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Logo string `json:"logo" db:"logo"`
Favicon string `json:"favicon" db:"favicon"`
Theme string `json:"theme" db:"theme"` // light, dark, auto
CustomCSS string `json:"custom_css" db:"custom_css"`
Public bool `json:"public" db:"public"`
ShowUptime bool `json:"show_uptime" db:"show_uptime"`
UserID string `json:"user" db:"user"`
Created time.Time `json:"created" db:"created"`
Updated time.Time `json:"updated" db:"updated"`
}
@@ -31,19 +31,31 @@ type StatusPageMonitor struct {
UserID string `json:"user" db:"user"`
}
// PublicIncident represents an incident for public display
type PublicIncident struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Severity string `json:"severity"`
StartedAt time.Time `json:"started_at"`
ResolvedAt time.Time `json:"resolved_at,omitempty"`
}
// PublicStatusPage represents a status page for public viewing
type PublicStatusPage struct {
ID string `json:"id"`
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
Theme string `json:"theme"`
CustomCSS string `json:"custom_css,omitempty"`
Monitors []PublicMonitorStatus `json:"monitors"`
OverallStatus string `json:"overall_status"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
Theme string `json:"theme"`
CustomCSS string `json:"custom_css,omitempty"`
Monitors []PublicMonitorStatus `json:"monitors"`
Incidents []PublicIncident `json:"incidents"`
OverallStatus string `json:"overall_status"`
UpdatedAt time.Time `json:"updated_at"`
}
// PublicMonitorStatus represents a monitor's status for public display
@@ -96,19 +108,19 @@ type StatusPageMonitorRequest struct {
// StatusPageResponse represents a status page response
type StatusPageResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
Theme string `json:"theme"`
Public bool `json:"public"`
ShowUptime bool `json:"show_uptime"`
MonitorCount int `json:"monitor_count"`
Created string `json:"created"`
Updated string `json:"updated"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
Theme string `json:"theme"`
Public bool `json:"public"`
ShowUptime bool `json:"show_uptime"`
MonitorCount int `json:"monitor_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// Overall status constants
+34 -3
View File
@@ -115,8 +115,26 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
newData, err := s.whois.LookupDomain(ctx, domainName)
if err != nil {
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
return err
log.Printf("[domain-scheduler] WHOIS lookup failed for %s: %v", domainName, err)
// Don't return early - try DNS resolution independently to verify domain is alive
newData = &domain.Domain{DomainName: domainName}
}
// Independent DNS resolution check: if WHOIS failed, try to resolve the domain directly
if err != nil {
ips, lookupErr := net.LookupHost(domainName)
if lookupErr == nil && len(ips) > 0 {
newData.IPv4Addresses = []string{}
newData.IPv6Addresses = []string{}
for _, ip := range ips {
if strings.Contains(ip, ":") {
newData.IPv6Addresses = append(newData.IPv6Addresses, ip)
} else {
newData.IPv4Addresses = append(newData.IPv4Addresses, ip)
}
}
log.Printf("[domain-scheduler] DNS resolution succeeded for %s despite WHOIS failure", domainName)
}
}
oldRecord := record.Fresh()
@@ -263,7 +281,20 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
status = domain.DomainStatusActive
}
} else {
status = domain.DomainStatusUnknown
// No expiry date from WHOIS - determine status from DNS resolution.
hasDNS := len(newData.IPv4Addresses) > 0 || len(newData.IPv6Addresses) > 0 || len(newData.NameServers) > 0
if hasDNS {
// DNS resolves means the domain is active and functioning.
// If we previously had a valid status (active/expiring), keep it.
// If status was unknown or empty, upgrade to active since DNS proves the domain exists.
if status == domain.DomainStatusUnknown || status == "" {
status = domain.DomainStatusActive
}
// Otherwise keep the existing valid status (active/expiring)
} else {
// No DNS resolution and no expiry date - we can't determine the domain's state
status = domain.DomainStatusUnknown
}
}
record.Set("status", status)
+9 -3
View File
@@ -177,7 +177,10 @@ func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain
// Parse events
var creationDate, expiryDate, updatedDate *time.Time
for _, event := range rdapResp.Events {
t, _ := time.Parse(time.RFC3339, event.EventDate)
t, err := time.Parse(time.RFC3339, event.EventDate)
if err != nil || t.IsZero() {
continue
}
switch event.EventAction {
case "registration":
creationDate = &t
@@ -831,8 +834,11 @@ func hasValidData(data *domain.WHOISData) bool {
if data == nil {
return false
}
// Accept if we got any meaningful data
if data.Dates.ExpiryDate != nil || data.Dates.CreationDate != nil {
// Accept if we got any meaningful date (non-nil and not zero)
if data.Dates.ExpiryDate != nil && !data.Dates.ExpiryDate.IsZero() {
return true
}
if data.Dates.CreationDate != nil && !data.Dates.CreationDate.IsZero() {
return true
}
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
+15
View File
@@ -2,6 +2,7 @@ package monitors
import (
"encoding/json"
"log"
"net/http"
"time"
@@ -251,6 +252,13 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
// Add to scheduler
h.scheduler.AddMonitor(record)
// Run initial check immediately so the monitor shows real status instead of pending
go func() {
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
}
}()
return e.JSON(http.StatusCreated, recordToResponse(record))
}
@@ -485,6 +493,13 @@ func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
h.scheduler.UpdateMonitor(record)
// Run an immediate check so the monitor shows real status instead of pending
go func() {
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
log.Printf("[monitor-api] Resume check failed for %s: %v", record.Id, err)
}
}()
return e.JSON(http.StatusOK, recordToResponse(record))
}
+7 -1
View File
@@ -390,9 +390,15 @@ func (s *Scheduler) AddMonitor(record *core.Record) {
func (s *Scheduler) UpdateMonitor(record *core.Record) {
m := recordToMonitor(record)
// Get existing scheduled monitor to preserve next check time if appropriate
// Get existing scheduled monitor
if sm, ok := s.monitors.GetOk(m.ID); ok {
sm.mu.Lock()
wasPaused := sm.Monitor.Status == monitor.StatusPaused || !sm.Monitor.Active
nowActive := m.Active && m.Status != monitor.StatusPaused
// If monitor just became active (resumed), reset NextCheck so it's checked immediately
if wasPaused && nowActive {
sm.NextCheck = time.Now()
}
sm.Monitor = m
sm.mu.Unlock()
} else {
+18
View File
@@ -392,6 +392,23 @@ func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.Publ
})
}
// Get active incidents for this user (not closed)
var publicIncidents []statuspage.PublicIncident
incidentRecords, _ := h.app.FindAllRecords("incidents",
dbx.NewExp("user = {:user} && status != {:status}",
dbx.Params{"user": record.GetString("user"), "status": "closed"}),
)
for _, incident := range incidentRecords {
publicIncidents = append(publicIncidents, statuspage.PublicIncident{
ID: incident.Id,
Title: incident.GetString("title"),
Description: incident.GetString("description"),
Status: incident.GetString("status"),
Severity: incident.GetString("severity"),
StartedAt: incident.GetDateTime("started_at").Time(),
})
}
return &statuspage.PublicStatusPage{
ID: record.Id,
Name: record.GetString("name"),
@@ -402,6 +419,7 @@ func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.Publ
Theme: record.GetString("theme"),
CustomCSS: record.GetString("custom_css"),
Monitors: publicMonitors,
Incidents: publicIncidents,
OverallStatus: overallStatus,
UpdatedAt: record.GetDateTime("updated").Time(),
}
@@ -99,71 +99,100 @@ export function CalendarView() {
if (isLoading) {
return (
<Card>
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Calendar View
<CalendarIcon className="h-5 w-5 text-primary" />
<span className="animate-pulse">Calendar View</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-96 flex items-center justify-center">Loading...</div>
<div className="h-96 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm">Loading calendar events...</p>
</div>
</CardContent>
</Card>
)
}
const today = new Date()
const isToday = (day: number) =>
day > 0 && today.getDate() === day && today.getMonth() === month && today.getFullYear() === year
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Calendar View
<Card className="w-full">
<CardHeader className="pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
<div className="p-2 bg-primary/10 rounded-lg">
<CalendarIcon className="h-5 w-5 text-primary" />
</div>
<span>Calendar View</span>
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={prevMonth}>
<Button variant="outline" size="icon" onClick={prevMonth} className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-medium min-w-[140px] text-center">
<span className="font-semibold min-w-[120px] sm:min-w-[160px] text-center text-sm sm:text-base px-2">
{monthNames[month]} {year}
</span>
<Button variant="outline" size="icon" onClick={nextMonth}>
<Button variant="outline" size="icon" onClick={nextMonth} className="h-8 w-8">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-7 gap-1 text-center text-sm font-medium text-muted-foreground mb-2">
<div>Sun</div>
<div>Mon</div>
<div>Tue</div>
<div>Wed</div>
<div>Thu</div>
<div>Fri</div>
<div>Sat</div>
<CardContent className="space-y-4">
{/* Day headers */}
<div className="grid grid-cols-7 gap-0.5 sm:gap-1 text-center text-[10px] sm:text-xs lg:text-sm font-medium text-muted-foreground">
{["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
<div key={i} className="py-1 sm:py-1.5">
<span className="hidden sm:inline">{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][i]}</span>
<span className="sm:hidden">{d}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-0.5 sm:gap-1 lg:gap-1.5">
{days.map((day, index) => (
<div
key={index}
className={`min-h-[100px] border rounded-lg p-2 ${day.day === 0 ? "bg-muted/30" : "bg-card"}`}
className={`
min-h-[48px] sm:min-h-[72px] lg:min-h-[96px]
border rounded sm:rounded-lg p-0.5 sm:p-1.5 lg:p-2
transition-all duration-150
${day.day === 0 ? "bg-muted/10 border-transparent" : "bg-card hover:bg-muted/30 hover:shadow-sm"}
${isToday(day.day) ? "ring-2 ring-primary ring-offset-1" : ""}
`}
>
{day.day > 0 && (
<>
<div className="font-medium text-sm mb-1">{day.day}</div>
<div className="space-y-1">
{day.events.map((event) => (
<div className={`
font-semibold text-[11px] sm:text-xs lg:text-sm mb-0.5 sm:mb-1
${isToday(day.day) ? "text-primary" : ""}
`}>
{day.day}
</div>
<div className="space-y-px sm:space-y-0.5">
{day.events.slice(0, 2).map((event, idx) => (
<Link
key={event.id}
href={event.link || "/calendar"}
className="text-xs p-1 rounded flex items-center gap-1"
className="
text-[9px] sm:text-[10px] lg:text-xs px-0.5 sm:px-1 py-px sm:py-0.5 rounded
flex items-center gap-0.5 sm:gap-1
hover:brightness-110 transition-all
"
style={{ backgroundColor: `${event.color}20`, color: event.color }}
title={event.title}
>
{getEventIcon(event.type)}
<span className="truncate">{event.title}</span>
<span className="truncate hidden lg:inline">{event.title}</span>
{idx === 1 && day.events.length > 2 && (
<span className="text-[8px] sm:text-[9px]">+{day.events.length - 2}</span>
)}
</Link>
))}
</div>
@@ -172,21 +201,26 @@ export function CalendarView() {
</div>
))}
</div>
{/* Upcoming Events Section */}
<div className="mt-6 border-t pt-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">Upcoming</h3>
<span className="text-xs text-muted-foreground">Next 12 months from this view</span>
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<span className="w-1.5 h-4 bg-primary rounded-full" />
Upcoming Events
</h3>
<span className="text-xs text-muted-foreground">Next 12 months</span>
</div>
{upcomingEvents.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{upcomingEvents.map((event) => (
<Link
key={event.id}
href={event.link || "/calendar"}
className="flex items-center gap-3 rounded-md border p-3 text-sm hover:bg-muted/50"
className="flex items-center gap-3 rounded-lg border p-3 text-sm hover:bg-muted/50 hover:border-primary/30 transition-all"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md shadow-sm"
style={{ backgroundColor: `${event.color}20`, color: event.color }}
>
{getEventIcon(event.type)}
@@ -196,7 +230,12 @@ export function CalendarView() {
<div className="text-xs text-muted-foreground">{event.date}</div>
</div>
{typeof event.days_until === "number" && (
<div className="text-xs text-muted-foreground">
<div className={`
text-xs font-medium px-2 py-1 rounded-full
${event.days_until === 0 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" : ""}
${event.days_until > 0 && event.days_until <= 7 ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" : ""}
${event.days_until > 7 ? "bg-muted text-muted-foreground" : ""}
`}>
{event.days_until === 0 ? "Today" : `${event.days_until}d`}
</div>
)}
@@ -204,27 +243,33 @@ export function CalendarView() {
))}
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No upcoming domain, SSL, or incident events found.
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
<div className="flex justify-center mb-2">
<CalendarIcon className="h-8 w-8 opacity-50" />
</div>
<p>No upcoming events in the next 12 months</p>
<p className="text-xs mt-1">Domain expiries, SSL renewals, and incidents will appear here</p>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-red-500" />
<span>Domain Expiring (&lt; 7 days)</span>
{/* Legend */}
<div className="mt-4 flex flex-wrap gap-3 text-xs sm:text-sm">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-red-100 dark:bg-red-900/20">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-red-700 dark:text-red-400 font-medium">&lt; 7 days</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-orange-500" />
<span>Domain Expiring (&lt; 30 days)</span>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/20">
<div className="w-2 h-2 rounded-full bg-orange-500" />
<span className="text-orange-700 dark:text-orange-400 font-medium">&lt; 30 days</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-purple-500" />
<span>SSL Expiry</span>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/20">
<div className="w-2 h-2 rounded-full bg-purple-500" />
<span className="text-purple-700 dark:text-purple-400 font-medium">SSL Expiry</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-gray-500" />
<span>Incident</span>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-900/20">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-gray-700 dark:text-gray-400 font-medium">Incident</span>
</div>
</div>
</CardContent>
+64 -23
View File
@@ -1,4 +1,4 @@
import { useLingui } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router"
import { memo, Suspense, useEffect, useMemo } from "react"
import { Link, $router } from "@/components/router"
@@ -7,8 +7,8 @@ import MonitorsTable from "@/components/monitors-table/monitors-table"
import DomainsTable from "@/components/domains-table/domains-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { Card, CardContent } from "@/components/ui/card"
import { Globe, AlertTriangle, Calendar } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Globe, AlertTriangle, Calendar, Server, Activity } from "lucide-react"
export default memo(() => {
const { t } = useLingui()
@@ -21,29 +21,70 @@ export default memo(() => {
() => (
<>
<div className="flex flex-col gap-8">
{/* Section 1: System Monitoring */}
<section>
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
</section>
{/* Active Alerts */}
<ActiveAlerts />
{/* Section 2: Website & Service Monitoring */}
<section>
<Suspense>
<MonitorsTable />
</Suspense>
</section>
{/* System Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>System Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track system resources, containers, and health</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<SystemsTable />
</Suspense>
</div>
</Card>
{/* Section 3: Domain Monitoring */}
<section>
<Suspense>
<DomainsTable />
</Suspense>
</section>
{/* Website & Service Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Website & Service Monitoring</Trans></CardTitle>
<CardDescription><Trans>Monitor websites, APIs, and services</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<MonitorsTable />
</Suspense>
</div>
</Card>
{/* Section 4: Quick Actions */}
{/* Domain Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Domain Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track domain expiry dates and DNS status</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<DomainsTable />
</Suspense>
</div>
</Card>
{/* Quick Actions */}
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
+376 -31
View File
@@ -1,11 +1,19 @@
import { memo, useEffect, useState } from "react"
import { useLingui } from "@lingui/react/macro"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Trans, useLingui } from "@lingui/react/macro"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { CalendarIcon, AlertTriangle, CheckCircle2, Clock } from "lucide-react"
import { getIncidents, type Incident } from "@/lib/incidents"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Separator } from "@/components/ui/separator"
import { useToast } from "@/components/ui/use-toast"
import { CalendarIcon, AlertTriangle, CheckCircle2, Clock, Plus, Eye, Check, X, MessageSquare, ShieldAlert } from "lucide-react"
import { getIncidents, acknowledgeIncident, resolveIncident, closeIncident, getIncidentUpdates, addIncidentUpdate, createIncident, type Incident, type IncidentUpdate } from "@/lib/incidents"
import { formatDate } from "@/lib/domains"
function StatusBadge({ status }: { status: string }) {
@@ -36,9 +44,41 @@ function SeverityBadge({ severity }: { severity: string }) {
return <Badge className={colors[severity] || "bg-gray-500"}>{severity}</Badge>
}
function IncidentSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-48" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full mb-3" />
<div className="flex gap-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default memo(() => {
const { t } = useLingui()
const { toast } = useToast()
const queryClient = useQueryClient()
const [filter, setFilter] = useState("all")
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const [isCreateOpen, setIsCreateOpen] = useState(false)
useEffect(() => {
document.title = `${t`Incidents`} / Beszel`
@@ -49,19 +89,97 @@ export default memo(() => {
queryFn: () => getIncidents(filter === "all" ? undefined : { status: filter }),
})
if (isLoading) {
return (
<div className="container">
<div className="p-4">Loading incidents...</div>
</div>
)
const { data: incidentUpdates = [] } = useQuery({
queryKey: ["incident-updates", selectedIncident?.id],
queryFn: () => getIncidentUpdates(selectedIncident!.id),
enabled: Boolean(selectedIncident) && isDetailOpen,
})
const acknowledgeMutation = useMutation({
mutationFn: acknowledgeIncident,
onSuccess: () => {
toast({ title: "Incident acknowledged" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
if (selectedIncident) {
queryClient.invalidateQueries({ queryKey: ["incident", selectedIncident.id] })
}
},
})
const resolveMutation = useMutation({
mutationFn: resolveIncident,
onSuccess: () => {
toast({ title: "Incident resolved" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
if (selectedIncident) {
queryClient.invalidateQueries({ queryKey: ["incident", selectedIncident.id] })
}
},
})
const closeMutation = useMutation({
mutationFn: closeIncident,
onSuccess: () => {
toast({ title: "Incident closed" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
if (selectedIncident) {
queryClient.invalidateQueries({ queryKey: ["incident", selectedIncident.id] })
setIsDetailOpen(false)
}
},
})
const addUpdateMutation = useMutation({
mutationFn: ({ id, message }: { id: string; message: string }) => addIncidentUpdate(id, message),
onSuccess: () => {
toast({ title: "Update added" })
queryClient.invalidateQueries({ queryKey: ["incident-updates", selectedIncident?.id] })
},
})
const createMutation = useMutation({
mutationFn: createIncident,
onSuccess: () => {
toast({ title: "Incident created" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
setIsCreateOpen(false)
},
})
const openDetail = (incident: Incident) => {
setSelectedIncident(incident)
setIsDetailOpen(true)
}
const getIncidentStats = () => {
const total = incidents.length
const open = incidents.filter((i) => i.status === "open").length
const acknowledged = incidents.filter((i) => i.status === "acknowledged").length
const resolved = incidents.filter((i) => i.status === "resolved").length
return { total, open, acknowledged, resolved }
}
const stats = getIncidentStats()
return (
<div className="container flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t`Incidents`}</h1>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldAlert className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-semibold">{t`Incidents`}</h1>
<p className="text-sm text-muted-foreground">
{stats.open > 0 && <span className="text-red-500 font-medium">{stats.open} open</span>}
{stats.open > 0 && stats.acknowledged > 0 && <span className="mx-1"></span>}
{stats.acknowledged > 0 && <span className="text-yellow-500 font-medium">{stats.acknowledged} acknowledged</span>}
{(stats.open > 0 || stats.acknowledged > 0) && <span className="mx-1"></span>}
{stats.total} total
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{["all", "open", "acknowledged", "resolved", "closed"].map((s) => (
<Button
key={s}
@@ -69,26 +187,43 @@ export default memo(() => {
size="sm"
onClick={() => setFilter(s)}
>
{s}
{s.charAt(0).toUpperCase() + s.slice(1)}
</Button>
))}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="ml-2">
<Plus className="h-4 w-4 mr-1" />
<Trans>Create</Trans>
</Button>
</DialogTrigger>
<CreateIncidentDialog onSubmit={(data) => createMutation.mutate(data)} isLoading={createMutation.isPending} />
</Dialog>
</div>
</div>
{incidents.length === 0 ? (
{isLoading ? (
<IncidentSkeleton />
) : incidents.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No incidents found.
<CardContent className="p-8 text-center">
<div className="flex justify-center mb-4">
<ShieldAlert className="h-12 w-12 text-muted-foreground opacity-50" />
</div>
<p className="text-muted-foreground">{t`No incidents found.`}</p>
<p className="text-sm text-muted-foreground mt-1">Monitor down events and manual incidents will appear here</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{incidents.map((incident: Incident) => (
<Card key={incident.id}>
<Card key={incident.id} className="hover:border-primary/30 transition-all">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{incident.title}</CardTitle>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{incident.title}</CardTitle>
</div>
<div className="flex items-center gap-2">
<SeverityBadge severity={incident.severity} />
<StatusBadge status={incident.status} />
</div>
@@ -100,23 +235,233 @@ export default memo(() => {
{incident.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
Started: {formatDate(incident.started_at)}
</span>
{incident.resolved_at && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Resolved: {formatDate(incident.resolved_at)}
<CalendarIcon className="h-3 w-3" />
Started: {formatDate(incident.started_at)}
</span>
)}
{incident.resolved_at && (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Resolved: {formatDate(incident.resolved_at)}
</span>
)}
</div>
<div className="flex items-center gap-2">
{incident.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={() => acknowledgeMutation.mutate(incident.id)}
disabled={acknowledgeMutation.isPending}
>
<Clock className="h-3 w-3 mr-1" />
<Trans>Acknowledge</Trans>
</Button>
)}
{(incident.status === "open" || incident.status === "acknowledged") && (
<Button
variant="outline"
size="sm"
onClick={() => resolveMutation.mutate({ id: incident.id })}
disabled={resolveMutation.isPending}
>
<Check className="h-3 w-3 mr-1" />
<Trans>Resolve</Trans>
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => openDetail(incident)}>
<Eye className="h-3 w-3 mr-1" />
<Trans>Details</Trans>
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Incident Detail Dialog */}
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{selectedIncident && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedIncident.title}
<SeverityBadge severity={selectedIncident.severity} />
<StatusBadge status={selectedIncident.status} />
</DialogTitle>
<DialogDescription>
{selectedIncident.description || "No description provided"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 my-4">
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4" />
Started: {formatDate(selectedIncident.started_at)}
</span>
{selectedIncident.resolved_at && (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
Resolved: {formatDate(selectedIncident.resolved_at)}
</span>
)}
</div>
<Separator />
{/* Updates Section */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Updates ({incidentUpdates.length})
</h3>
{incidentUpdates.length > 0 ? (
<div className="space-y-3">
{incidentUpdates.map((update: IncidentUpdate) => (
<Card key={update.id}>
<CardContent className="p-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<p className="text-sm">{update.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(update.created_at)}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No updates yet</p>
)}
{/* Add Update Form */}
{(selectedIncident.status === "open" || selectedIncident.status === "acknowledged") && (
<form
onSubmit={(e) => {
e.preventDefault()
const form = e.target as HTMLFormElement
const message = (form.elements.namedItem("message") as HTMLInputElement).value
if (message.trim()) {
addUpdateMutation.mutate({ id: selectedIncident.id, message })
form.reset()
}
}}
className="mt-4 flex gap-2"
>
<Input
name="message"
placeholder="Add an update..."
className="flex-1"
disabled={addUpdateMutation.isPending}
/>
<Button type="submit" size="sm" disabled={addUpdateMutation.isPending}>
<Trans>Add</Trans>
</Button>
</form>
)}
</div>
</div>
<DialogFooter className="gap-2">
{selectedIncident.status !== "closed" && (
<Button
variant="outline"
onClick={() => closeMutation.mutate(selectedIncident.id)}
disabled={closeMutation.isPending}
>
<X className="h-4 w-4 mr-1" />
<Trans>Close</Trans>
</Button>
)}
<Button onClick={() => setIsDetailOpen(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
})
function CreateIncidentDialog({
onSubmit,
isLoading,
}: {
onSubmit: (data: { title: string; description: string; severity: string; type: string }) => void
isLoading: boolean
}) {
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [severity, setSeverity] = useState("medium")
const [type, setType] = useState("manual")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({ title, description, severity, type })
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Create New Incident</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Manually create an incident for tracking</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Incident title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the incident"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="severity">Severity</Label>
<Select value={severity} onValueChange={setSeverity}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading || !title.trim()}>
{isLoading ? <Trans>Creating...</Trans> : <Trans>Create Incident</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -264,7 +264,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
}
const cutoff = now - (ranges[timeRange] || ranges["24h"])
return heartbeats.filter((h: HeartbeatRow) => {
const t = new Date(h.time || h.timestamp).getTime()
const t = new Date(h.time || h.timestamp || "").getTime()
return t >= cutoff
})
}, [heartbeats, timeRange])
@@ -276,7 +276,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
.slice()
.reverse()
.map((h: HeartbeatRow) => ({
time: new Date(h.time || h.timestamp).toLocaleTimeString(),
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(),
responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0,
}))
@@ -414,6 +414,36 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
/>
</div>
{/* Pending / No Data State */}
{monitor.status === "pending" && !heartbeats?.length && (
<Card className="border-yellow-500/20 bg-yellow-50/5 dark:bg-yellow-950/10">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-500/10 rounded-lg">
<Clock className="h-5 w-5 text-yellow-500" />
</div>
<div>
<p className="font-medium">Initial check pending</p>
<p className="text-sm text-muted-foreground">
This monitor has not been checked yet. Click "Check Now" to run the first check.
</p>
</div>
</div>
<Button
variant="default"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
<Trans>Check Now</Trans>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Combined Uptime & Response Chart */}
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -1,16 +1,56 @@
import { memo } from "react"
import { memo, Suspense } from "react"
import { Trans, useLingui } from "@lingui/react/macro"
import MonitorsTable from "@/components/monitors-table/monitors-table"
import DomainsTable from "@/components/domains-table/domains-table"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Activity, Globe } from "lucide-react"
const MonitoringPage = memo(function MonitoringPage() {
const { t } = useLingui()
return (
<div className="grid gap-8 mb-14">
<section>
<MonitorsTable />
</section>
<section>
<DomainsTable />
</section>
<div className="flex flex-col gap-8 mb-14">
<h1 className="text-2xl font-semibold">{t`Monitoring`}</h1>
{/* Website & Service Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Website & Service Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track uptime, response times, and service health</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<MonitorsTable />
</Suspense>
</div>
</Card>
{/* Domain Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Domain Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track domain expiry dates and DNS status</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<DomainsTable />
</Suspense>
</div>
</Card>
</div>
)
})
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getPublicStatusPage, type PublicStatusPage, type PublicMonitorStatus } from "@/lib/statuspages"
import { getPublicStatusPage, type PublicStatusPage, type PublicMonitorStatus, type PublicIncident } from "@/lib/statuspages"
import { Activity, CheckCircle2, XCircle, AlertTriangle, Clock, Shield, RefreshCw } from "lucide-react"
// Status configurations with colors matching github-statuses design
@@ -389,6 +389,61 @@ export default function PublicStatusPage({ slug }: { slug: string }) {
</div>
</section>
{/* Active Incidents */}
{data.incidents && data.incidents.length > 0 && (
<section className="sp-incidents-section">
<div className="sp-group-header">
<h3 className="sp-group-title">Active Incidents</h3>
</div>
<div className="sp-incidents-list">
{data.incidents.map((incident) => (
<div
key={incident.id}
className="sp-incident-card"
style={{
backgroundColor:
incident.severity === "critical"
? "rgba(239, 68, 68, 0.1)"
: incident.severity === "high"
? "rgba(249, 115, 22, 0.1)"
: "rgba(234, 179, 8, 0.1)",
borderLeft:
incident.severity === "critical"
? "4px solid #ef4444"
: incident.severity === "high"
? "4px solid #f97316"
: "4px solid #eab308",
}}
>
<div className="sp-incident-header">
<AlertTriangle
className="sp-incident-icon"
style={{
color:
incident.severity === "critical"
? "#ef4444"
: incident.severity === "high"
? "#f97316"
: "#eab308",
}}
/>
<span className="sp-incident-status" style={{ textTransform: "capitalize" }}>
{incident.status}
</span>
</div>
<h4 className="sp-incident-title">{incident.title}</h4>
{incident.description && (
<p className="sp-incident-description">{incident.description}</p>
)}
<span className="sp-incident-time">
Started: {new Date(incident.started_at).toLocaleString()}
</span>
</div>
))}
</div>
</section>
)}
{/* Monitor Groups */}
{groupNames.map((groupName) => (
<section key={groupName} className="sp-group-section">
@@ -23,6 +23,7 @@ import {
FilterIcon,
LayoutGridIcon,
LayoutListIcon,
PlusIcon,
Settings2Icon,
XIcon,
} from "lucide-react"
@@ -49,6 +50,7 @@ import AlertButton from "../alerts/alert-button"
import { $router, Link } from "../router"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
import { AddSystemDialog } from "../add-system"
type ViewMode = "table" | "grid"
type StatusFilter = "all" | SystemRecord["status"]
@@ -62,6 +64,7 @@ export default function SystemsTable() {
const pausedSystems = $pausedSystems.get()
const { i18n, t } = useLingui()
const [filter, setFilter] = useState<string>("")
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [sorting, setSorting] = useBrowserStorage<SortingState>(
"sortMode",
@@ -146,6 +149,10 @@ export default function SystemsTable() {
</div>
<div className="flex gap-2 ms-auto w-full md:w-80">
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
<div className="relative flex-1">
<Input
placeholder={t`Filter...`}
@@ -302,28 +309,31 @@ export default function SystemsTable() {
])
return (
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
{CardHead}
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
) : (
// grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{rows?.length ? (
rows.map((row) => {
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
})
) : (
<div className="col-span-full text-center py-8">
<Trans>No systems found.</Trans>
</div>
)}
</div>
)}
</Card>
<>
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
{CardHead}
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
) : (
// grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{rows?.length ? (
rows.map((row) => {
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
})
) : (
<div className="col-span-full text-center py-8">
<Trans>No systems found.</Trans>
</div>
)}
</div>
)}
</Card>
<AddSystemDialog open={isAddDialogOpen} setOpen={setIsAddDialogOpen} />
</>
)
}
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+16 -5
View File
@@ -29,8 +29,8 @@ export interface StatusPageMonitor {
export interface PublicMonitorStatus {
id: string
name: string
display_name: string
group: string
display_name?: string
group?: string
status: string
uptime_24h: number
uptime_7d: number
@@ -38,16 +38,27 @@ export interface PublicMonitorStatus {
last_check: string
}
export interface PublicIncident {
id: string
title: string
description: string
status: string
severity: string
started_at: string
resolved_at?: string
}
export interface PublicStatusPage {
id: string
name: string
title: string
description: string
logo: string
favicon: string
theme: StatusPageTheme
logo?: string
favicon?: string
theme?: string
custom_css?: string
monitors: PublicMonitorStatus[]
incidents: PublicIncident[]
overall_status: string
updated_at: string
}