Files
Beszel/internal/site/src/components/monitors-table/monitors-table.tsx
T
Tomas Dvorak 298c514087 ui(site): enhance table components and visibility
Refactor domain, monitor, and system tables to improve visual clarity and information density.

- Update badge and pill styles in `domains-table` and `monitors-table` to use solid background colors for better contrast.
- Integrate `ContainersTable` into `MonitorCard` and `SystemCard` to provide nested container visibility.
- Simplify `MonitorsTable` by removing the unused network view mode.
- Improve layout and spacing in various table components for better readability.
2026-05-07 10:36:21 +02:00

874 lines
25 KiB
Go

import { Trans, useLingui } from "@lingui/react/macro"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
AlertTriangle,
ArrowDownIcon,
ArrowUpIcon,
CheckCircleIcon,
Edit3Icon,
FilterIcon,
GlobeIcon,
LayoutGridIcon,
LayoutListIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
Settings2Icon,
TagIcon,
Trash2Icon,
XCircle,
XCircleIcon,
} from "lucide-react"
import { memo, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useToast } from "@/components/ui/use-toast"
import {
deleteMonitor,
getMonitorTypeLabel,
listMonitors,
manualCheck,
pauseMonitor,
resumeMonitor,
type Monitor,
type MonitorStatus,
type MonitorType,
formatUptime,
formatPing,
} from "@/lib/monitors"
import { cn, useBrowserStorage } from "@/lib/utils"
import { AddMonitorDialog } from "./add-monitor-dialog"
import { Link } from "@/components/router"
import ContainersTable from "../containers-table/containers-table"
// Status indicator component
function StatusIndicator({ status }: { status: MonitorStatus }) {
const colors = {
up: "bg-green-500",
down: "bg-red-500",
pending: "bg-yellow-400",
paused: "bg-gray-400",
maintenance: "bg-blue-500",
}
const icons = {
up: CheckCircleIcon,
down: XCircleIcon,
pending: RefreshCwIcon,
paused: PauseIcon,
maintenance: RefreshCwIcon,
}
const Icon = icons[status] || RefreshCwIcon
return (
<div className="flex items-center gap-2">
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status])} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{status}</span>
</div>
)
}
// Monitor Card component for grid view
function MonitorCard({
monitor,
onEdit,
}: {
monitor: Monitor
onEdit: (m: Monitor) => void
}) {
const { toast } = useToast()
const queryClient = useQueryClient()
const checkMutation = useMutation({
mutationFn: manualCheck,
onSuccess: (result) => {
toast({
title: `Check complete`,
description: `${monitor.name} is ${result.status} (${formatPing(result.ping)})`,
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
const pauseMutation = useMutation({
mutationFn: monitor.status === "paused" ? resumeMonitor : pauseMonitor,
onSuccess: () => {
toast({
title: monitor.status === "paused" ? "Monitor resumed" : "Monitor paused",
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
const deleteMutation = useMutation({
mutationFn: deleteMonitor,
onSuccess: () => {
toast({ title: "Monitor deleted" })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
return (
<div className="rounded-lg border bg-card p-4 space-y-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer min-w-0">
<div className="shrink-0">
<StatusIndicator status={monitor.status} />
</div>
<div className="min-w-0">
<div className="font-medium truncate hover:underline">{monitor.name}</div>
<div className="text-xs text-muted-foreground truncate">
{monitor.url || monitor.hostname}
{monitor.port ? `:${monitor.port}` : ""}
</div>
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<Edit3Icon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(monitor)}>
<Edit3Icon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteMutation.mutate(monitor.id)}
className="text-destructive"
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">Type</div>
<div className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getMonitorTypeLabel(monitor.type)}
</div>
</div>
{/* Uptime - Prominent pill display */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
{monitor.uptime_stats?.uptime_7d !== undefined && monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
)}
</div>
<UptimeDots heartbeats={monitor.recent_heartbeats} />
</div>
<div className="flex items-center justify-between text-sm">
<div className="text-xs text-muted-foreground">Response</div>
<div>
{monitor.last_check ? (
formatPing(monitor.uptime_stats?.last_ping || 0)
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
{monitor.tags && monitor.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{monitor.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<TagIcon className="h-3 w-3" />
{tag}
</span>
))}
</div>
)}
{/* Containers Section */}
<div className="pt-3 border-t">
<ContainersTable />
</div>
<div className="flex items-center gap-2 pt-2 border-t">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => checkMutation.mutate(monitor.id)}
disabled={checkMutation.isPending}
>
<RefreshCwIcon
className={cn(
"h-4 w-4 mr-1",
checkMutation.isPending && "animate-spin"
)}
/>
Check
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Check now</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => pauseMutation.mutate(monitor.id)}
disabled={pauseMutation.isPending}
>
{monitor.status === "paused" ? (
<><PlayIcon className="h-4 w-4 mr-1" /> Resume</>
) : (
<><PauseIcon className="h-4 w-4 mr-1" /> Pause</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{monitor.status === "paused" ? "Resume" : "Pause"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)
}
// Uptime pill badge component - big and visible
function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) {
let colorClass = "bg-green-500 text-white border-green-500"
let icon = <CheckCircleIcon className="h-3.5 w-3.5" />
if (uptime < 99.9) {
colorClass = "bg-green-500 text-white border-green-500"
}
if (uptime < 95) {
colorClass = "bg-yellow-500 text-white border-yellow-500"
icon = <AlertTriangle className="h-3.5 w-3.5" />
}
if (uptime < 90) {
colorClass = "bg-red-500 text-white border-red-500"
icon = <XCircle className="h-3.5 w-3.5" />
}
return (
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border-2 ${colorClass}`}>
{icon}
<span className="text-sm font-bold">{formatUptime(uptime)}</span>
<span className="text-[10px] font-medium uppercase opacity-90">{label}</span>
</div>
)
}
// Uptime bar component with pill style
function UptimeBar({ stats }: { stats?: Record<string, number> }) {
const uptime24h = stats?.uptime_24h ?? 100
const uptime7d = stats?.uptime_7d ?? 100
const uptime30d = stats?.uptime_30d ?? 100
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<UptimePill uptime={uptime24h} label="24h" />
{uptime7d !== 100 && uptime7d !== uptime24h && (
<UptimePill uptime={uptime7d} label="7d" />
)}
{uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && (
<UptimePill uptime={uptime30d} label="30d" />
)}
</div>
</div>
)
}
// Mini uptime dots visualization
function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) {
if (!heartbeats || heartbeats.length === 0) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-3 w-2 rounded-sm bg-muted" />
))}
</div>
)
}
// Take last 12 heartbeats
const recent = heartbeats.slice(-12)
return (
<div className="flex gap-0.5">
{recent.map((hb, i) => (
<div
key={i}
className={cn(
"h-3 w-2 rounded-sm transition-colors",
hb.status === "up" ? "bg-green-500" :
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)}
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`}
/>
))}
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => (
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" />
))}
</div>
)
}
// Monitor row component
function MonitorRow({
monitor,
onEdit,
}: {
monitor: Monitor
onEdit: (m: Monitor) => void
}) {
const { toast } = useToast()
const queryClient = useQueryClient()
const checkMutation = useMutation({
mutationFn: manualCheck,
onSuccess: (result) => {
toast({
title: `Check complete`,
description: `${monitor.name} is ${result.status} (${formatPing(result.ping)})`,
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
onError: () => {
toast({
title: "Check failed",
variant: "destructive",
})
},
})
const pauseMutation = useMutation({
mutationFn: monitor.status === "paused" ? resumeMonitor : pauseMonitor,
onSuccess: () => {
toast({
title: monitor.status === "paused" ? "Monitor resumed" : "Monitor paused",
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
const deleteMutation = useMutation({
mutationFn: deleteMonitor,
onSuccess: () => {
toast({ title: "Monitor deleted" })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
return (
<TableRow>
<TableCell>
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer">
<GlobeIcon className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium hover:underline">{monitor.name}</div>
<div className="text-xs text-muted-foreground">
{monitor.url || monitor.hostname}
{monitor.port ? `:${monitor.port}` : ""}
</div>
</div>
</Link>
</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getMonitorTypeLabel(monitor.type)}
</span>
</TableCell>
<TableCell>
<StatusIndicator status={monitor.status} />
</TableCell>
<TableCell>
{monitor.last_check ? (
<div className="text-sm">
{formatPing(monitor.uptime_stats?.last_ping || 0)}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<UptimeBar stats={monitor.uptime_stats} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{monitor.tags?.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<TagIcon className="h-3 w-3" />
{tag}
</span>
))}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => checkMutation.mutate(monitor.id)}
disabled={checkMutation.isPending}
>
<RefreshCwIcon
className={cn(
"h-4 w-4",
checkMutation.isPending && "animate-spin"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Check now</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => pauseMutation.mutate(monitor.id)}
disabled={pauseMutation.isPending}
>
{monitor.status === "paused" ? (
<PlayIcon className="h-4 w-4" />
) : (
<PauseIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{monitor.status === "paused" ? "Resume" : "Pause"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit3Icon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(monitor)}>
<Edit3Icon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteMutation.mutate(monitor.id)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
}
type ViewMode = "table" | "grid"
type StatusFilter = "all" | MonitorStatus
type TypeFilter = "all" | MonitorType
// Main component
export default memo(function MonitorsTable() {
const { t } = useLingui()
const [filter, setFilter] = useState("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [tagFilter, setTagFilter] = useState<string>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
"monitorsViewMode",
window.innerWidth < 1024 ? "grid" : "table"
)
const { data: monitors = [], isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: listMonitors,
refetchInterval: 30000,
})
// Extract all unique types
const allTypes = useMemo(() => {
const typeSet = new Set<MonitorType>()
monitors.forEach((m) => typeSet.add(m.type))
return Array.from(typeSet).sort()
}, [monitors])
// Filter by status first
const statusFilteredMonitors = useMemo(() => {
if (statusFilter === "all") return monitors
return monitors.filter((m) => m.status === statusFilter)
}, [monitors, statusFilter])
// Then filter by search text and type
const filteredMonitors = useMemo(() => {
let result = statusFilteredMonitors
if (filter) {
const f = filter.toLowerCase()
result = result.filter(
(m) =>
m.name.toLowerCase().includes(f) ||
(m.url || "").toLowerCase().includes(f) ||
(m.hostname || "").toLowerCase().includes(f)
)
}
if (tagFilter !== "all") {
result = result.filter((m) => m.tags?.includes(tagFilter))
}
if (typeFilter !== "all") {
result = result.filter((m) => m.type === typeFilter)
}
return result
}, [statusFilteredMonitors, filter, tagFilter, typeFilter])
// Extract all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>()
monitors.forEach((m) => m.tags?.forEach((tag) => tagSet.add(tag)))
return Array.from(tagSet).sort()
}, [monitors])
const stats = useMemo(() => {
const total = monitors.length
const up = monitors.filter((m) => m.status === "up").length
const down = monitors.filter((m) => m.status === "down").length
const paused = monitors.filter((m) => m.status === "paused").length
const pending = monitors.filter((m) => m.status === "pending").length
const maintenance = monitors.filter((m) => m.status === "maintenance").length
return { total, up, down, paused, pending, maintenance }
}, [monitors])
return (
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 pb-5">
<div className="flex flex-col gap-4">
{/* Title row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-xl mb-2 flex items-center gap-2">
<GlobeIcon className="h-5 w-5 text-primary" />
<Trans>Status</Trans>
</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Trans>Monitor websites, APIs, and services</Trans>
<span className="text-xs text-muted-foreground">
({stats.up} <ArrowUpIcon className="inline h-3 w-3 text-green-500" />
{stats.down > 0 && (
<>
{" "}
{stats.down}{" "}
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
</>
)}
{stats.paused > 0 && (
<>
{" "}
{stats.paused} <PauseIcon className="inline h-3 w-3 text-gray-400" />
</>
)}
/ {stats.total})
</span>
</CardDescription>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add Monitor</Trans>
</Button>
</div>
{/* Quick status filters */}
<div className="flex flex-wrap gap-1.5">
{[
{ key: "all", label: `All ${stats.total}`, color: "bg-primary" },
{ key: "up", label: `Up ${stats.up}`, color: "bg-green-500" },
{ key: "down", label: `Down ${stats.down}`, color: "bg-red-500" },
{ key: "paused", label: `Paused ${stats.paused}`, color: "bg-gray-400" },
].map((s) => (
<Button
key={s.key}
variant={statusFilter === s.key ? "default" : "outline"}
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setStatusFilter(s.key as StatusFilter)}
disabled={s.key !== "all" && parseInt(s.label.split(" ")[1]) === 0}
>
<span className={`h-2 w-2 rounded-full ${s.color}`} />
{s.label.split(" ")[0]}
<span className="text-[10px] opacity-70">{s.label.split(" ")[1]}</span>
</Button>
))}
</div>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Input
placeholder={t`Filter monitors...`}
onChange={(e) => setFilter(e.target.value)}
value={filter}
className="w-full"
/>
</div>
{allTypes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<GlobeIcon className="me-1.5 size-4 opacity-80" />
{typeFilter === "all" ? t`Type` : getMonitorTypeLabel(typeFilter)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
<DropdownMenuRadioItem value="all">
<Trans>All Types</Trans>
</DropdownMenuRadioItem>
{allTypes.map((type) => (
<DropdownMenuRadioItem key={type} value={type}>
{getMonitorTypeLabel(type)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{allTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<TagIcon className="me-1.5 size-4 opacity-80" />
{tagFilter === "all" ? t`Tags` : tagFilter}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={tagFilter} onValueChange={setTagFilter}>
<DropdownMenuRadioItem value="all">
<Trans>All Tags</Trans>
</DropdownMenuRadioItem>
{allTags.map((tag) => (
<DropdownMenuRadioItem key={tag} value={tag}>
{tag}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>Options</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
{/* Layout */}
<DropdownMenuLabel className="flex items-center gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Layout</Trans>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<DropdownMenuRadioItem value="table" className="gap-2">
<LayoutListIcon className="size-4" />
<Trans>Table</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="grid" className="gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{/* Status Filter */}
<DropdownMenuLabel className="flex items-center gap-2">
<FilterIcon className="size-4" />
<Trans>Status</Trans>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<DropdownMenuRadioItem value="all">
<Trans>All ({stats.total})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="up">
<Trans>Up ({stats.up})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="down">
<Trans>Down ({stats.down})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused">
<Trans>Paused ({stats.paused})</Trans>
</DropdownMenuRadioItem>
{stats.pending > 0 && (
<DropdownMenuRadioItem value="pending">
<Trans>Pending ({stats.pending})</Trans>
</DropdownMenuRadioItem>
)}
{stats.maintenance > 0 && (
<DropdownMenuRadioItem value="maintenance">
<Trans>Maintenance ({stats.maintenance})</Trans>
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">
<Trans>Loading...</Trans>
</div>
) : filteredMonitors.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{filter || statusFilter !== "all" || tagFilter !== "all" || typeFilter !== "all" ? (
<Trans>No monitors match your filters.</Trans>
) : (
<div>
<p className="mb-4">
<Trans>No monitors configured yet.</Trans>
</p>
<Button onClick={() => setIsAddDialogOpen(true)} variant="outline">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add your first monitor</Trans>
</Button>
</div>
)}
</div>
) : viewMode === "table" ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Name</Trans>
</TableHead>
<TableHead>
<Trans>Type</Trans>
</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Response</Trans>
</TableHead>
<TableHead>
<Trans>Uptime (24h)</Trans>
</TableHead>
<TableHead>
<Trans>Tags</Trans>
</TableHead>
<TableHead className="text-right">
<Trans>Actions</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMonitors.map((monitor) => (
<MonitorRow
key={monitor.id}
monitor={monitor}
onEdit={setEditingMonitor}
/>
))}
</TableBody>
</Table>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{filteredMonitors.map((monitor) => (
<MonitorCard
key={monitor.id}
monitor={monitor}
onEdit={setEditingMonitor}
/>
))}
</div>
)}
</CardContent>
{/* Add Monitor Dialog */}
<AddMonitorDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
{/* Edit Monitor Dialog */}
{editingMonitor && (
<AddMonitorDialog
open={!!editingMonitor}
onOpenChange={(open) => !open && setEditingMonitor(null)}
monitor={editingMonitor}
isEdit
/>
)}
</Card>
)
})