mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +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:
@@ -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 (< 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">< 7 days</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-orange-500" />
|
||||
<span>Domain Expiring (< 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">< 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user