mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-04 21:32:57 +00:00
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:
@@ -4,18 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// StatusPage represents a public status page configuration
|
// StatusPage represents a public status page configuration
|
||||||
type StatusPage struct {
|
type StatusPage struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
Slug string `json:"slug" db:"slug"`
|
Slug string `json:"slug" db:"slug"`
|
||||||
Title string `json:"title" db:"title"`
|
Title string `json:"title" db:"title"`
|
||||||
Description string `json:"description" db:"description"`
|
Description string `json:"description" db:"description"`
|
||||||
Logo string `json:"logo" db:"logo"`
|
Logo string `json:"logo" db:"logo"`
|
||||||
Favicon string `json:"favicon" db:"favicon"`
|
Favicon string `json:"favicon" db:"favicon"`
|
||||||
Theme string `json:"theme" db:"theme"` // light, dark, auto
|
Theme string `json:"theme" db:"theme"` // light, dark, auto
|
||||||
CustomCSS string `json:"custom_css" db:"custom_css"`
|
CustomCSS string `json:"custom_css" db:"custom_css"`
|
||||||
Public bool `json:"public" db:"public"`
|
Public bool `json:"public" db:"public"`
|
||||||
ShowUptime bool `json:"show_uptime" db:"show_uptime"`
|
ShowUptime bool `json:"show_uptime" db:"show_uptime"`
|
||||||
UserID string `json:"user" db:"user"`
|
UserID string `json:"user" db:"user"`
|
||||||
Created time.Time `json:"created" db:"created"`
|
Created time.Time `json:"created" db:"created"`
|
||||||
Updated time.Time `json:"updated" db:"updated"`
|
Updated time.Time `json:"updated" db:"updated"`
|
||||||
}
|
}
|
||||||
@@ -31,19 +31,31 @@ type StatusPageMonitor struct {
|
|||||||
UserID string `json:"user" db:"user"`
|
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
|
// PublicStatusPage represents a status page for public viewing
|
||||||
type PublicStatusPage struct {
|
type PublicStatusPage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Logo string `json:"logo"`
|
Logo string `json:"logo"`
|
||||||
Favicon string `json:"favicon"`
|
Favicon string `json:"favicon"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
CustomCSS string `json:"custom_css,omitempty"`
|
CustomCSS string `json:"custom_css,omitempty"`
|
||||||
Monitors []PublicMonitorStatus `json:"monitors"`
|
Monitors []PublicMonitorStatus `json:"monitors"`
|
||||||
OverallStatus string `json:"overall_status"`
|
Incidents []PublicIncident `json:"incidents"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
OverallStatus string `json:"overall_status"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicMonitorStatus represents a monitor's status for public display
|
// PublicMonitorStatus represents a monitor's status for public display
|
||||||
@@ -96,19 +108,19 @@ type StatusPageMonitorRequest struct {
|
|||||||
|
|
||||||
// StatusPageResponse represents a status page response
|
// StatusPageResponse represents a status page response
|
||||||
type StatusPageResponse struct {
|
type StatusPageResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Logo string `json:"logo"`
|
Logo string `json:"logo"`
|
||||||
Favicon string `json:"favicon"`
|
Favicon string `json:"favicon"`
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
Public bool `json:"public"`
|
Public bool `json:"public"`
|
||||||
ShowUptime bool `json:"show_uptime"`
|
ShowUptime bool `json:"show_uptime"`
|
||||||
MonitorCount int `json:"monitor_count"`
|
MonitorCount int `json:"monitor_count"`
|
||||||
Created string `json:"created"`
|
Created string `json:"created"`
|
||||||
Updated string `json:"updated"`
|
Updated string `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall status constants
|
// Overall status constants
|
||||||
|
|||||||
@@ -115,8 +115,26 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
|||||||
|
|
||||||
newData, err := s.whois.LookupDomain(ctx, domainName)
|
newData, err := s.whois.LookupDomain(ctx, domainName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
|
log.Printf("[domain-scheduler] WHOIS lookup failed for %s: %v", domainName, err)
|
||||||
return 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()
|
oldRecord := record.Fresh()
|
||||||
@@ -263,7 +281,20 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
|||||||
status = domain.DomainStatusActive
|
status = domain.DomainStatusActive
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
record.Set("status", status)
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain
|
|||||||
// Parse events
|
// Parse events
|
||||||
var creationDate, expiryDate, updatedDate *time.Time
|
var creationDate, expiryDate, updatedDate *time.Time
|
||||||
for _, event := range rdapResp.Events {
|
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 {
|
switch event.EventAction {
|
||||||
case "registration":
|
case "registration":
|
||||||
creationDate = &t
|
creationDate = &t
|
||||||
@@ -831,8 +834,11 @@ func hasValidData(data *domain.WHOISData) bool {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Accept if we got any meaningful data
|
// Accept if we got any meaningful date (non-nil and not zero)
|
||||||
if data.Dates.ExpiryDate != nil || data.Dates.CreationDate != nil {
|
if data.Dates.ExpiryDate != nil && !data.Dates.ExpiryDate.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if data.Dates.CreationDate != nil && !data.Dates.CreationDate.IsZero() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
|
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package monitors
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -251,6 +252,13 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
|
|||||||
// Add to scheduler
|
// Add to scheduler
|
||||||
h.scheduler.AddMonitor(record)
|
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))
|
return e.JSON(http.StatusCreated, recordToResponse(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +493,13 @@ func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
h.scheduler.UpdateMonitor(record)
|
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))
|
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -390,9 +390,15 @@ func (s *Scheduler) AddMonitor(record *core.Record) {
|
|||||||
func (s *Scheduler) UpdateMonitor(record *core.Record) {
|
func (s *Scheduler) UpdateMonitor(record *core.Record) {
|
||||||
m := recordToMonitor(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 {
|
if sm, ok := s.monitors.GetOk(m.ID); ok {
|
||||||
sm.mu.Lock()
|
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.Monitor = m
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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{
|
return &statuspage.PublicStatusPage{
|
||||||
ID: record.Id,
|
ID: record.Id,
|
||||||
Name: record.GetString("name"),
|
Name: record.GetString("name"),
|
||||||
@@ -402,6 +419,7 @@ func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.Publ
|
|||||||
Theme: record.GetString("theme"),
|
Theme: record.GetString("theme"),
|
||||||
CustomCSS: record.GetString("custom_css"),
|
CustomCSS: record.GetString("custom_css"),
|
||||||
Monitors: publicMonitors,
|
Monitors: publicMonitors,
|
||||||
|
Incidents: publicIncidents,
|
||||||
OverallStatus: overallStatus,
|
OverallStatus: overallStatus,
|
||||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,71 +99,100 @@ export function CalendarView() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CalendarIcon className="h-5 w-5" />
|
<CalendarIcon className="h-5 w-5 text-primary" />
|
||||||
Calendar View
|
<span className="animate-pulse">Calendar View</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const isToday = (day: number) =>
|
||||||
|
day > 0 && today.getDate() === day && today.getMonth() === month && today.getFullYear() === year
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
|
||||||
<CalendarIcon className="h-5 w-5" />
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
Calendar View
|
<CalendarIcon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span>Calendar View</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</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}
|
{monthNames[month]} {year}
|
||||||
</span>
|
</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" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-sm font-medium text-muted-foreground mb-2">
|
{/* Day headers */}
|
||||||
<div>Sun</div>
|
<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">
|
||||||
<div>Mon</div>
|
{["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
|
||||||
<div>Tue</div>
|
<div key={i} className="py-1 sm:py-1.5">
|
||||||
<div>Wed</div>
|
<span className="hidden sm:inline">{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][i]}</span>
|
||||||
<div>Thu</div>
|
<span className="sm:hidden">{d}</span>
|
||||||
<div>Fri</div>
|
</div>
|
||||||
<div>Sat</div>
|
))}
|
||||||
</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) => (
|
{days.map((day, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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 && (
|
{day.day > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium text-sm mb-1">{day.day}</div>
|
<div className={`
|
||||||
<div className="space-y-1">
|
font-semibold text-[11px] sm:text-xs lg:text-sm mb-0.5 sm:mb-1
|
||||||
{day.events.map((event) => (
|
${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
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
href={event.link || "/calendar"}
|
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 }}
|
style={{ backgroundColor: `${event.color}20`, color: event.color }}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
>
|
>
|
||||||
{getEventIcon(event.type)}
|
{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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,21 +201,26 @@ export function CalendarView() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Events Section */}
|
||||||
<div className="mt-6 border-t pt-4">
|
<div className="mt-6 border-t pt-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold">Upcoming</h3>
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Next 12 months from this view</span>
|
<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>
|
</div>
|
||||||
{upcomingEvents.length > 0 ? (
|
{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) => (
|
{upcomingEvents.map((event) => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
href={event.link || "/calendar"}
|
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
|
<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 }}
|
style={{ backgroundColor: `${event.color}20`, color: event.color }}
|
||||||
>
|
>
|
||||||
{getEventIcon(event.type)}
|
{getEventIcon(event.type)}
|
||||||
@@ -196,7 +230,12 @@ export function CalendarView() {
|
|||||||
<div className="text-xs text-muted-foreground">{event.date}</div>
|
<div className="text-xs text-muted-foreground">{event.date}</div>
|
||||||
</div>
|
</div>
|
||||||
{typeof event.days_until === "number" && (
|
{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`}
|
{event.days_until === 0 ? "Today" : `${event.days_until}d`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -204,27 +243,33 @@ export function CalendarView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||||
No upcoming domain, SSL, or incident events found.
|
<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>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Legend */}
|
||||||
<div className="w-3 h-3 rounded bg-red-500" />
|
<div className="mt-4 flex flex-wrap gap-3 text-xs sm:text-sm">
|
||||||
<span>Domain Expiring (< 7 days)</span>
|
<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">< 7 days</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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-3 h-3 rounded bg-orange-500" />
|
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
<span>Domain Expiring (< 30 days)</span>
|
<span className="text-orange-700 dark:text-orange-400 font-medium">< 30 days</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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-3 h-3 rounded bg-purple-500" />
|
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
||||||
<span>SSL Expiry</span>
|
<span className="text-purple-700 dark:text-purple-400 font-medium">SSL Expiry</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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-3 h-3 rounded bg-gray-500" />
|
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||||
<span>Incident</span>
|
<span className="text-gray-700 dark:text-gray-400 font-medium">Incident</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||||
import { Link, $router } from "@/components/router"
|
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 DomainsTable from "@/components/domains-table/domains-table"
|
||||||
import { ActiveAlerts } from "@/components/active-alerts"
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Globe, AlertTriangle, Calendar } from "lucide-react"
|
import { Globe, AlertTriangle, Calendar, Server, Activity } from "lucide-react"
|
||||||
|
|
||||||
export default memo(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
@@ -21,29 +21,70 @@ export default memo(() => {
|
|||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/* Section 1: System Monitoring */}
|
{/* Active Alerts */}
|
||||||
<section>
|
<ActiveAlerts />
|
||||||
<ActiveAlerts />
|
|
||||||
<Suspense>
|
|
||||||
<SystemsTable />
|
|
||||||
</Suspense>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Section 2: Website & Service Monitoring */}
|
{/* System Monitoring Section */}
|
||||||
<section>
|
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
<Suspense>
|
<CardHeader className="p-0 mb-4 pb-4 border-b">
|
||||||
<MonitorsTable />
|
<div className="flex items-center gap-3">
|
||||||
</Suspense>
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
</section>
|
<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 */}
|
{/* Website & Service Monitoring Section */}
|
||||||
<section>
|
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
<Suspense>
|
<CardHeader className="p-0 mb-4 pb-4 border-b">
|
||||||
<DomainsTable />
|
<div className="flex items-center gap-3">
|
||||||
</Suspense>
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
</section>
|
<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">
|
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { memo, useEffect, useState } from "react"
|
import { memo, useEffect, useState } from "react"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CalendarIcon, AlertTriangle, CheckCircle2, Clock } from "lucide-react"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { getIncidents, type Incident } from "@/lib/incidents"
|
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"
|
import { formatDate } from "@/lib/domains"
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
@@ -36,9 +44,41 @@ function SeverityBadge({ severity }: { severity: string }) {
|
|||||||
return <Badge className={colors[severity] || "bg-gray-500"}>{severity}</Badge>
|
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(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [filter, setFilter] = useState("all")
|
const [filter, setFilter] = useState("all")
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null)
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false)
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t`Incidents`} / Beszel`
|
document.title = `${t`Incidents`} / Beszel`
|
||||||
@@ -49,19 +89,97 @@ export default memo(() => {
|
|||||||
queryFn: () => getIncidents(filter === "all" ? undefined : { status: filter }),
|
queryFn: () => getIncidents(filter === "all" ? undefined : { status: filter }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
const { data: incidentUpdates = [] } = useQuery({
|
||||||
return (
|
queryKey: ["incident-updates", selectedIncident?.id],
|
||||||
<div className="container">
|
queryFn: () => getIncidentUpdates(selectedIncident!.id),
|
||||||
<div className="p-4">Loading incidents...</div>
|
enabled: Boolean(selectedIncident) && isDetailOpen,
|
||||||
</div>
|
})
|
||||||
)
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="container flex flex-col gap-6">
|
<div className="container flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-semibold">{t`Incidents`}</h1>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-2">
|
<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) => (
|
{["all", "open", "acknowledged", "resolved", "closed"].map((s) => (
|
||||||
<Button
|
<Button
|
||||||
key={s}
|
key={s}
|
||||||
@@ -69,26 +187,43 @@ export default memo(() => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setFilter(s)}
|
onClick={() => setFilter(s)}
|
||||||
>
|
>
|
||||||
{s}
|
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{incidents.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<IncidentSkeleton />
|
||||||
|
) : incidents.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
<CardContent className="p-8 text-center">
|
||||||
No incidents found.
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{incidents.map((incident: Incident) => (
|
{incidents.map((incident: Incident) => (
|
||||||
<Card key={incident.id}>
|
<Card key={incident.id} className="hover:border-primary/30 transition-all">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<CardTitle className="text-lg">{incident.title}</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-2">
|
<CardTitle className="text-lg">{incident.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<SeverityBadge severity={incident.severity} />
|
<SeverityBadge severity={incident.severity} />
|
||||||
<StatusBadge status={incident.status} />
|
<StatusBadge status={incident.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -100,23 +235,233 @@ export default memo(() => {
|
|||||||
{incident.description}
|
{incident.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<CalendarIcon className="h-3 w-3" />
|
|
||||||
Started: {formatDate(incident.started_at)}
|
|
||||||
</span>
|
|
||||||
{incident.resolved_at && (
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
<CalendarIcon className="h-3 w-3" />
|
||||||
Resolved: {formatDate(incident.resolved_at)}
|
Started: {formatDate(incident.started_at)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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"])
|
const cutoff = now - (ranges[timeRange] || ranges["24h"])
|
||||||
return heartbeats.filter((h: HeartbeatRow) => {
|
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
|
return t >= cutoff
|
||||||
})
|
})
|
||||||
}, [heartbeats, timeRange])
|
}, [heartbeats, timeRange])
|
||||||
@@ -276,7 +276,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
|||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((h: HeartbeatRow) => ({
|
.map((h: HeartbeatRow) => ({
|
||||||
time: new Date(h.time || h.timestamp).toLocaleTimeString(),
|
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(),
|
||||||
responseTime: h.ping || 0,
|
responseTime: h.ping || 0,
|
||||||
status: h.status === "up" ? 1 : 0,
|
status: h.status === "up" ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
@@ -414,6 +414,36 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Combined Uptime & Response Chart */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<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 MonitorsTable from "@/components/monitors-table/monitors-table"
|
||||||
import DomainsTable from "@/components/domains-table/domains-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 MonitoringPage = memo(function MonitoringPage() {
|
||||||
|
const { t } = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8 mb-14">
|
<div className="flex flex-col gap-8 mb-14">
|
||||||
<section>
|
<h1 className="text-2xl font-semibold">{t`Monitoring`}</h1>
|
||||||
<MonitorsTable />
|
|
||||||
</section>
|
{/* Website & Service Monitoring Section */}
|
||||||
<section>
|
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
<DomainsTable />
|
<CardHeader className="p-0 mb-4 pb-4 border-b">
|
||||||
</section>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useMemo } from "react"
|
import { useEffect, useState, useMemo } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
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"
|
import { Activity, CheckCircle2, XCircle, AlertTriangle, Clock, Shield, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
// Status configurations with colors matching github-statuses design
|
// Status configurations with colors matching github-statuses design
|
||||||
@@ -389,6 +389,61 @@ export default function PublicStatusPage({ slug }: { slug: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Monitor Groups */}
|
||||||
{groupNames.map((groupName) => (
|
{groupNames.map((groupName) => (
|
||||||
<section key={groupName} className="sp-group-section">
|
<section key={groupName} className="sp-group-section">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
FilterIcon,
|
FilterIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
|
PlusIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -49,6 +50,7 @@ import AlertButton from "../alerts/alert-button"
|
|||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
|
import { AddSystemDialog } from "../add-system"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | SystemRecord["status"]
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
@@ -62,6 +64,7 @@ export default function SystemsTable() {
|
|||||||
const pausedSystems = $pausedSystems.get()
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>("")
|
const [filter, setFilter] = useState<string>("")
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
"sortMode",
|
"sortMode",
|
||||||
@@ -146,6 +149,10 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<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">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
@@ -302,28 +309,31 @@ export default function SystemsTable() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
<>
|
||||||
{CardHead}
|
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
{viewMode === "table" ? (
|
{CardHead}
|
||||||
// table layout
|
{viewMode === "table" ? (
|
||||||
<div className="rounded-md">
|
// table layout
|
||||||
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<div className="rounded-md">
|
||||||
</div>
|
<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">
|
// grid layout
|
||||||
{rows?.length ? (
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
rows.map((row) => {
|
{rows?.length ? (
|
||||||
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.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 className="col-span-full text-center py-8">
|
||||||
</div>
|
<Trans>No systems found.</Trans>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
</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 }
|
||||||
@@ -29,8 +29,8 @@ export interface StatusPageMonitor {
|
|||||||
export interface PublicMonitorStatus {
|
export interface PublicMonitorStatus {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
display_name?: string
|
||||||
group: string
|
group?: string
|
||||||
status: string
|
status: string
|
||||||
uptime_24h: number
|
uptime_24h: number
|
||||||
uptime_7d: number
|
uptime_7d: number
|
||||||
@@ -38,16 +38,27 @@ export interface PublicMonitorStatus {
|
|||||||
last_check: string
|
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 {
|
export interface PublicStatusPage {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
logo: string
|
logo?: string
|
||||||
favicon: string
|
favicon?: string
|
||||||
theme: StatusPageTheme
|
theme?: string
|
||||||
custom_css?: string
|
custom_css?: string
|
||||||
monitors: PublicMonitorStatus[]
|
monitors: PublicMonitorStatus[]
|
||||||
|
incidents: PublicIncident[]
|
||||||
overall_status: string
|
overall_status: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user