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
@@ -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
}