This commit is contained in:
Tomas Dvorak
2026-04-29 11:32:39 +02:00
parent 193839bd27
commit 67254f89a9
20 changed files with 1792 additions and 1094 deletions
@@ -3,30 +3,30 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
AlertCircle,
Globe,
Shield,
} from "lucide-react"
import { Link } from "@/components/router"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react"
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
import { formatDate } from "@/lib/domains"
export function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date())
const { data: events, isLoading } = useQuery({
queryKey: ["calendar-events"],
queryFn: getCalendarEvents,
})
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
const queryRange = useMemo(() => {
const from = new Date(year, month, 1)
const to = new Date(year, month + 13, 0)
return {
from: toDateString(from),
to: toDateString(to),
}
}, [year, month])
const { data: events, isLoading } = useQuery({
queryKey: ["calendar-events", queryRange.from, queryRange.to],
queryFn: () => getCalendarEvents(queryRange),
})
const daysInMonth = useMemo(() => {
return new Date(year, month + 1, 0).getDate()
}, [year, month])
@@ -37,22 +37,30 @@ export function CalendarView() {
const days = useMemo(() => {
const d: { day: number; events: CalendarEvent[] }[] = []
// Empty cells for days before start of month
for (let i = 0; i < firstDayOfMonth; i++) {
d.push({ day: 0, events: [] })
}
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
const dayEvents = events?.filter((e) => e.date === dateStr) || []
d.push({ day, events: dayEvents })
}
return d
}, [year, month, daysInMonth, firstDayOfMonth, events])
const upcomingEvents = useMemo(() => {
const today = toDateString(new Date())
return (events || [])
.filter((event) => event.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8)
}, [events])
const prevMonth = () => {
setCurrentDate(new Date(year, month - 1, 1))
}
@@ -75,8 +83,18 @@ export function CalendarView() {
}
const monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
if (isLoading) {
@@ -130,24 +148,23 @@ export function CalendarView() {
{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-[100px] border rounded-lg p-2 ${day.day === 0 ? "bg-muted/30" : "bg-card"}`}
>
{day.day > 0 && (
<>
<div className="font-medium text-sm mb-1">{day.day}</div>
<div className="space-y-1">
{day.events.map((event) => (
<div
<Link
key={event.id}
href={event.link || "/calendar"}
className="text-xs p-1 rounded flex items-center gap-1"
style={{ backgroundColor: event.color + "20", color: event.color }}
style={{ backgroundColor: `${event.color}20`, color: event.color }}
title={event.title}
>
{getEventIcon(event.type)}
<span className="truncate">{event.title}</span>
</div>
</Link>
))}
</div>
</>
@@ -155,6 +172,43 @@ export function CalendarView() {
</div>
))}
</div>
<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>
{upcomingEvents.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{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"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded"
style={{ backgroundColor: `${event.color}20`, color: event.color }}
>
{getEventIcon(event.type)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{event.title}</div>
<div className="text-xs text-muted-foreground">{event.date}</div>
</div>
{typeof event.days_until === "number" && (
<div className="text-xs text-muted-foreground">
{event.days_until === 0 ? "Today" : `${event.days_until}d`}
</div>
)}
</Link>
))}
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No upcoming domain, SSL, or incident events found.
</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" />
@@ -177,3 +231,7 @@ export function CalendarView() {
</Card>
)
}
function toDateString(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
@@ -35,40 +35,13 @@ import {
} from "@/lib/monitors"
const MONITOR_TYPES: { value: MonitorType; label: string; group: string }[] = [
// General
{ value: "http", label: "HTTP", group: "General" },
{ value: "https", label: "HTTPS", group: "General" },
{ value: "keyword", label: "HTTP Keyword", group: "General" },
{ value: "json-query", label: "HTTP JSON", group: "General" },
{ value: "grpc-keyword", label: "gRPC Keyword", group: "General" },
{ value: "real-browser", label: "Browser Engine (Beta)", group: "General" },
{ value: "tcp", label: "TCP Port", group: "General" },
{ value: "ping", label: "Ping", group: "General" },
{ value: "dns", label: "DNS", group: "General" },
{ value: "docker", label: "Docker Container", group: "General" },
{ value: "push", label: "Push", group: "General" },
{ value: "manual", label: "Manual", group: "General" },
// Network / Protocol
{ value: "mqtt", label: "MQTT", group: "Network / Protocol" },
{ value: "rabbitmq", label: "RabbitMQ", group: "Network / Protocol" },
{ value: "kafka-producer", label: "Kafka Producer", group: "Network / Protocol" },
{ value: "smtp", label: "SMTP", group: "Network / Protocol" },
{ value: "snmp", label: "SNMP", group: "Network / Protocol" },
{ value: "websocket-upgrade", label: "WebSocket Upgrade", group: "Network / Protocol" },
{ value: "sip-options", label: "SIP Options Ping", group: "Network / Protocol" },
{ value: "tailscale-ping", label: "Tailscale Ping", group: "Network / Protocol" },
{ value: "globalping", label: "Globalping", group: "Network / Protocol" },
// Database
{ value: "mysql", label: "MySQL / MariaDB", group: "Database" },
{ value: "postgresql", label: "PostgreSQL", group: "Database" },
{ value: "mongodb", label: "MongoDB", group: "Database" },
{ value: "redis", label: "Redis", group: "Database" },
{ value: "sqlserver", label: "Microsoft SQL Server", group: "Database" },
{ value: "oracledb", label: "Oracle DB", group: "Database" },
{ value: "radius", label: "RADIUS", group: "Database" },
// Games
{ value: "gamedig", label: "GameDig", group: "Game Server" },
{ value: "steam", label: "Steam API", group: "Game Server" },
]
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]
@@ -82,12 +55,7 @@ interface AddMonitorDialogProps {
isEdit?: boolean
}
export function AddMonitorDialog({
open,
onOpenChange,
monitor,
isEdit = false,
}: AddMonitorDialogProps) {
export function AddMonitorDialog({ open, onOpenChange, monitor, isEdit = false }: AddMonitorDialogProps) {
const { t } = useLingui()
const { toast } = useToast()
const queryClient = useQueryClient()
@@ -123,7 +91,7 @@ export function AddMonitorDialog({
const [dbName, setDbName] = useState("")
const [mqttTopic, setMqttTopic] = useState("")
const [grpcKeyword, setGrpcKeyword] = useState("")
// Notification settings
const [notifyOnDown, setNotifyOnDown] = useState(true)
const [notifyOnRecover, setNotifyOnRecover] = useState(true)
@@ -163,7 +131,7 @@ export function AddMonitorDialog({
setIgnoreTLSError(monitor.ignore_tls_error || false)
setCertExpiryNotification(monitor.cert_expiry_notification || false)
setCertExpiryDays(monitor.cert_expiry_days || 14)
// Load notification settings
setNotifyOnDown(monitor.notify_on_down !== false)
setNotifyOnRecover(monitor.notify_on_recover !== false)
@@ -199,7 +167,7 @@ export function AddMonitorDialog({
setIgnoreTLSError(false)
setCertExpiryNotification(false)
setCertExpiryDays(14)
// Reset notification settings
setNotifyOnDown(true)
setNotifyOnRecover(true)
@@ -234,8 +202,7 @@ export function AddMonitorDialog({
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) =>
updateMonitor(id, data),
mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) => updateMonitor(id, data),
onSuccess: () => {
toast({ title: t`Monitor updated successfully` })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
@@ -264,9 +231,7 @@ export function AddMonitorDialog({
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
hostname: needsHostname ? hostname.trim() || undefined : undefined,
port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type)
? method
: undefined,
method: ["http", "https", "keyword", "json-query"].includes(type) ? method : undefined,
headers: headers.trim() || undefined,
body: body.trim() || undefined,
interval,
@@ -279,10 +244,7 @@ export function AddMonitorDialog({
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined,
ignore_tls_error:
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
ignore_tls_error: type === "https" || type === "keyword" || type === "json-query" ? ignoreTLSError : undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
// Notification settings
@@ -312,9 +274,7 @@ export function AddMonitorDialog({
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
hostname: needsHostname ? hostname.trim() || undefined : undefined,
port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type)
? method
: undefined,
method: ["http", "https", "keyword", "json-query"].includes(type) ? method : undefined,
headers: headers.trim() || undefined,
body: body.trim() || undefined,
interval,
@@ -327,10 +287,7 @@ export function AddMonitorDialog({
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined,
ignore_tls_error:
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
ignore_tls_error: type === "https" || type === "keyword" || type === "json-query" ? ignoreTLSError : undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
// Notification settings
@@ -356,9 +313,54 @@ export function AddMonitorDialog({
}
}
const needsUrl = ["http", "https", "keyword", "json-query", "grpc-keyword", "real-browser", "websocket-upgrade", "push"].includes(type)
const needsHostname = ["tcp", "ping", "dns", "mqtt", "rabbitmq", "kafka-producer", "smtp", "snmp", "sip-options", "tailscale-ping", "globalping", "mysql", "postgresql", "mongodb", "redis", "sqlserver", "oracledb", "radius", "gamedig", "steam"].includes(type)
const needsPort = ["tcp", "smtp", "mysql", "postgresql", "redis", "sqlserver", "oracledb", "radius", "mqtt", "rabbitmq", "kafka-producer", "gamedig", "steam", "snmp"].includes(type)
const needsUrl = [
"http",
"https",
"keyword",
"json-query",
"grpc-keyword",
"real-browser",
"websocket-upgrade",
"push",
].includes(type)
const needsHostname = [
"tcp",
"ping",
"dns",
"mqtt",
"rabbitmq",
"kafka-producer",
"smtp",
"snmp",
"sip-options",
"tailscale-ping",
"globalping",
"mysql",
"postgresql",
"mongodb",
"redis",
"sqlserver",
"oracledb",
"radius",
"gamedig",
"steam",
].includes(type)
const needsPort = [
"tcp",
"smtp",
"mysql",
"postgresql",
"redis",
"sqlserver",
"oracledb",
"radius",
"mqtt",
"rabbitmq",
"kafka-producer",
"gamedig",
"steam",
"snmp",
].includes(type)
const needsHttpOptions = ["http", "https", "keyword", "json-query"].includes(type)
const needsKeyword = type === "keyword"
const needsJsonQuery = type === "json-query"
@@ -374,13 +376,9 @@ export function AddMonitorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}
</DialogTitle>
<DialogTitle>{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}</DialogTitle>
<DialogDescription>
<Trans>
Configure a monitor to track website or service availability.
</Trans>
<Trans>Configure a monitor to track website or service availability.</Trans>
</DialogDescription>
</DialogHeader>
@@ -419,10 +417,7 @@ export function AddMonitorDialog({
<Label htmlFor="type">
<Trans>Monitor Type</Trans> *
</Label>
<Select
value={type}
onValueChange={(v) => setType(v as MonitorType)}
>
<Select value={type} onValueChange={(v) => setType(v as MonitorType)}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
@@ -431,10 +426,10 @@ export function AddMonitorDialog({
<SelectGroup key={group}>
<SelectLabel>{group}</SelectLabel>
{MONITOR_TYPES.filter((mt) => mt.group === group).map((mt) => (
<SelectItem key={mt.value} value={mt.value}>
{mt.label}
</SelectItem>
))}
<SelectItem key={mt.value} value={mt.value}>
{mt.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
@@ -481,11 +476,7 @@ export function AddMonitorDialog({
type="number"
placeholder={t`443`}
value={port}
onChange={(e) =>
setPort(
e.target.value ? Number(e.target.value) : ""
)
}
onChange={(e) => setPort(e.target.value ? Number(e.target.value) : "")}
required
/>
</div>
@@ -524,11 +515,7 @@ export function AddMonitorDialog({
required
/>
<div className="flex items-center gap-2 mt-2">
<Switch
id="invertKeyword"
checked={invertKeyword}
onCheckedChange={setInvertKeyword}
/>
<Switch id="invertKeyword" checked={invertKeyword} onCheckedChange={setInvertKeyword} />
<Label htmlFor="invertKeyword">
<Trans>Invert match (alert if keyword found)</Trans>
</Label>
@@ -570,10 +557,7 @@ export function AddMonitorDialog({
<Label htmlFor="dnsResolverMode">
<Trans>Record Type</Trans>
</Label>
<Select
value={dnsResolverMode}
onValueChange={setDnsResolverMode}
>
<Select value={dnsResolverMode} onValueChange={setDnsResolverMode}>
<SelectTrigger id="dnsResolverMode">
<SelectValue />
</SelectTrigger>
@@ -774,11 +758,7 @@ export function AddMonitorDialog({
{needsTlsOptions && (
<div className="space-y-4 border rounded-lg p-4">
<div className="flex items-center gap-2">
<Switch
id="ignoreTLSError"
checked={ignoreTLSError}
onCheckedChange={setIgnoreTLSError}
/>
<Switch id="ignoreTLSError" checked={ignoreTLSError} onCheckedChange={setIgnoreTLSError} />
<Label htmlFor="ignoreTLSError">
<Trans>Ignore TLS/SSL errors</Trans>
</Label>
@@ -791,17 +771,13 @@ export function AddMonitorDialog({
{/* Status Change Notifications */}
<div className="space-y-4 border rounded-lg p-4">
<h4 className="font-medium text-sm">Status Change Alerts</h4>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyOnDown">Notify when monitor goes down</Label>
<p className="text-xs text-muted-foreground">Send alert when service becomes unavailable</p>
</div>
<Switch
id="notifyOnDown"
checked={notifyOnDown}
onCheckedChange={setNotifyOnDown}
/>
<Switch id="notifyOnDown" checked={notifyOnDown} onCheckedChange={setNotifyOnDown} />
</div>
<div className="flex items-center justify-between">
@@ -809,11 +785,7 @@ export function AddMonitorDialog({
<Label htmlFor="notifyOnRecover">Notify when monitor recovers</Label>
<p className="text-xs text-muted-foreground">Send alert when service comes back up</p>
</div>
<Switch
id="notifyOnRecover"
checked={notifyOnRecover}
onCheckedChange={setNotifyOnRecover}
/>
<Switch id="notifyOnRecover" checked={notifyOnRecover} onCheckedChange={setNotifyOnRecover} />
</div>
<div className="space-y-3">
@@ -848,7 +820,7 @@ export function AddMonitorDialog({
{/* Performance Alerts */}
<div className="space-y-4 border rounded-lg p-4">
<h4 className="font-medium text-sm">Performance Alerts</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
@@ -947,11 +919,7 @@ export function AddMonitorDialog({
<Label htmlFor="quietHoursEnabled">Enable quiet hours</Label>
<p className="text-xs text-muted-foreground">Suppress notifications during specific hours</p>
</div>
<Switch
id="quietHoursEnabled"
checked={quietHoursEnabled}
onCheckedChange={setQuietHoursEnabled}
/>
<Switch id="quietHoursEnabled" checked={quietHoursEnabled} onCheckedChange={setQuietHoursEnabled} />
</div>
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-4 pl-4 border-l-2">
@@ -980,12 +948,7 @@ export function AddMonitorDialog({
</Tabs>
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isPending}>
+462 -454
View File
@@ -1,18 +1,18 @@
import { memo, useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { memo, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import {
@@ -33,8 +33,17 @@ import {
User,
Mail,
Building,
type LucideIcon,
} from "lucide-react"
import { getDomain, getDomainHistory, refreshDomain, deleteDomain, formatDate, formatDays } from "@/lib/domains"
import {
type DomainHistory,
getDomain,
getDomainHistory,
refreshDomain,
deleteDomain,
formatDate,
formatDays,
} from "@/lib/domains"
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts"
import { Link, navigate } from "@/components/router"
import { DomainDialog } from "@/components/domains-table/domain-dialog"
@@ -62,7 +71,19 @@ function StatusBadge({ status }: { status: string }) {
}
// Info card component
function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: string; value: string; icon: any; subtitle?: string; className?: string }) {
function InfoCard({
title,
value,
icon: Icon,
subtitle,
className,
}: {
title: string
value: string
icon: LucideIcon
subtitle?: string
className?: string
}) {
return (
<Card className={className}>
<CardContent className="p-4">
@@ -83,6 +104,7 @@ function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: st
export default memo(function DomainDetail({ id }: { id: string }) {
const { toast } = useToast()
const queryClient = useQueryClient()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
@@ -100,8 +122,11 @@ export default memo(function DomainDetail({ id }: { id: string }) {
const handleRefresh = async () => {
try {
await refreshDomain(id)
toast({ title: "Domain refresh started" })
const refreshed = await refreshDomain(id)
queryClient.setQueryData(["domain", id], refreshed)
queryClient.invalidateQueries({ queryKey: ["domain-history", id] })
queryClient.invalidateQueries({ queryKey: ["domains"] })
toast({ title: "Domain refreshed" })
} catch (error) {
toast({
title: "Failed to refresh domain",
@@ -130,21 +155,6 @@ export default memo(function DomainDetail({ id }: { id: string }) {
}
}
// Prepare chart data from history (events by date)
const chartData = useMemo(() => {
if (!history?.length) return []
const counts: Record<string, number> = {}
history.forEach((h: any) => {
const d = h.created_at
? new Date(h.created_at).toISOString().split("T")[0]
: "Unknown"
counts[d] = (counts[d] || 0) + 1
})
return Object.entries(counts)
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date))
}, [history])
if (isDomainLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -218,29 +228,33 @@ export default memo(function DomainDetail({ id }: { id: string }) {
{/* Info Grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoCard
title="Registrar"
value={domain.registrar_name || "Unknown"}
icon={Server}
/>
<InfoCard title="Registrar" value={domain.registrar_name || "Unknown"} icon={Server} />
<InfoCard
title="Domain Expiry"
value={formatDate(domain.expiry_date)}
subtitle={formatDays(domain.days_until_expiry)}
icon={Calendar}
className={domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30 ? "text-yellow-600" : ""}
className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-600"
: ""
}
/>
<InfoCard
title="SSL Expiry"
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/>
<InfoCard
title="Location"
value={domain.host_country || "Unknown"}
subtitle={domain.host_isp}
value={[domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || "Unknown"}
subtitle={domain.host_isp || domain.host_org}
icon={MapPin}
/>
</div>
@@ -273,438 +287,432 @@ export default memo(function DomainDetail({ id }: { id: string }) {
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
/>
<Bar dataKey="days" radius={[0, 4, 4, 0]}>
{[
{ days: domain.days_until_expiry ?? 0 },
{ days: domain.ssl_days_until ?? 0 },
].map((entry, index) => (
{[{ days: domain.days_until_expiry ?? 0 }, { days: domain.ssl_days_until ?? 0 }].map(
(entry, index) => (
<Cell
key={`cell-${index}`}
fill={
entry.days <= 14
? "#ef4444"
: entry.days <= 30
? "#f59e0b"
: "#22c55e"
}
fill={entry.days <= 14 ? "#ef4444" : entry.days <= 30 ? "#f59e0b" : "#22c55e"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)
)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid gap-4">
{/* Expiry Timeline Chart */}
<Card>
<CardHeader>
<CardTitle>Change Timeline</CardTitle>
<CardDescription>Recent detected domain, DNS, SSL, and registrar changes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{history?.slice(0, 8).map((event) => (
<div key={event.id} className="flex items-start gap-3 rounded-md border p-3">
<Badge variant="outline" className="mt-0.5">
{event.change_type}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{event.field_name}</p>
<p className="text-xs text-muted-foreground break-words">
{event.old_value || "Unknown"} {"->"} {event.new_value || "Unknown"}
</p>
<p className="text-xs text-muted-foreground mt-1">{formatDate(event.created_at)}</p>
</div>
</div>
))}
{!history?.length && <p className="text-sm text-muted-foreground">No changes recorded yet.</p>}
</div>
</CardContent>
</Card>
<div className="grid gap-4">
{/* Expiry Timeline Chart */}
<Card>
<CardHeader>
<CardTitle>History Events</CardTitle>
<CardDescription>Domain changes and check events over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#3b82f6" name="Events" />
</BarChart>
</ResponsiveContainer>
{/* Additional Info */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>IP Addresses</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{domain.ipv4_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv4</Badge>
<code className="text-sm">{ip}</code>
</div>
</CardContent>
</Card>
{/* Additional Info */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>IP Addresses</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{domain.ipv4_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv4</Badge>
<code className="text-sm">{ip}</code>
</div>
))}
{domain.ipv6_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv6</Badge>
<code className="text-sm">{ip}</code>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-muted-foreground">No IP addresses found</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchase Price</span>
<span className="font-medium">${domain.purchase_price || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Current Value</span>
<span className="font-medium">${domain.current_value || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Renewal Cost</span>
<span className="font-medium">${domain.renewal_cost || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"}>
{domain.auto_renew ? "Yes" : "No"}
</Badge>
</div>
</CardContent>
</Card>
</div>
{/* Notes */}
{domain.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
</CardContent>
</Card>
)}
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>DNS Records</CardTitle>
<CardDescription>Name servers, mail exchangers, and text records</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Nameservers */}
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
Nameservers
<Badge variant="secondary" className="ml-2">{domain.name_servers?.length || 0}</Badge>
</h4>
<div className="space-y-1">
{domain.name_servers?.map((ns: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">NS</Badge>
<code className="text-sm">{ns}</code>
</div>
))}
{!domain.name_servers?.length && (
<p className="text-muted-foreground text-sm">No nameservers found</p>
)}
</div>
))}
{domain.ipv6_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv6</Badge>
<code className="text-sm">{ip}</code>
</div>
{/* MX Records */}
{domain.mx_records && domain.mx_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Mail className="h-4 w-4" />
Mail Servers (MX)
<Badge variant="secondary" className="ml-2">{domain.mx_records.length}</Badge>
</h4>
<div className="space-y-1">
{domain.mx_records?.map((mx: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">MX</Badge>
<code className="text-sm">{mx}</code>
</div>
))}
</div>
</div>
)}
{/* TXT Records */}
{domain.txt_records && domain.txt_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT Records
<Badge variant="secondary" className="ml-2">{domain.txt_records.length}</Badge>
</h4>
<div className="space-y-1">
{domain.txt_records?.map((txt: string, i: number) => (
<div key={i} className="flex items-start gap-2">
<Badge variant="outline">TXT</Badge>
<code className="text-sm break-all">{txt}</code>
</div>
))}
</div>
</div>
)}
{/* DNSSEC */}
{domain.dnssec && (
<div>
<h4 className="text-sm font-medium mb-2">DNSSEC</h4>
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}>
{domain.dnssec}
</Badge>
</div>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>SSL Certificate Details</CardTitle>
<CardDescription>Certificate information and validity</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{domain.ssl_valid_to ? (
<>
{/* Validity */}
<div className="grid sm:grid-cols-2 gap-4">
<InfoCard
title="Valid From"
value={formatDate(domain.ssl_valid_from)}
icon={Calendar}
/>
<InfoCard
title="Valid Until"
value={formatDate(domain.ssl_valid_to)}
subtitle={formatDays(domain.ssl_days_until)}
icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
/>
</div>
{/* Issuer & Subject */}
<div className="space-y-4">
<div className="flex items-start gap-3">
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Issuer</p>
<p className="font-medium">{domain.ssl_issuer || "Unknown"}</p>
{domain.ssl_issuer_country && (
<p className="text-sm text-muted-foreground">Country: {domain.ssl_issuer_country}</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Subject</p>
<p className="font-medium">{domain.ssl_subject || "Unknown"}</p>
</div>
</div>
</div>
{/* Technical Details */}
<div className="grid sm:grid-cols-2 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-muted-foreground mb-1">Key Size</p>
<p className="font-medium">{domain.ssl_key_size ? `${domain.ssl_key_size} bits` : "Unknown"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">Signature Algorithm</p>
<p className="font-medium">{domain.ssl_signature_algo || "Unknown"}</p>
</div>
{domain.ssl_fingerprint && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground mb-1">Fingerprint</p>
<code className="text-sm break-all">{domain.ssl_fingerprint}</code>
</div>
)}
</div>
</>
) : (
<div className="text-center py-8">
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No SSL certificate information available</p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>WHOIS Information</CardTitle>
<CardDescription>Domain registration details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Registrar */}
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Building className="h-4 w-4" />
Registrar
</h4>
<div className="grid sm:grid-cols-2 gap-2">
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrar_name || "Unknown"}</p>
</div>
{domain.registrar_id && (
<div>
<p className="text-sm text-muted-foreground">IANA ID</p>
<p className="font-medium">{domain.registrar_id}</p>
</div>
)}
{domain.registry_domain_id && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground">Registry Domain ID</p>
<p className="font-medium">{domain.registry_domain_id}</p>
</div>
)}
</div>
</div>
{/* Important Dates */}
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
Important Dates
</h4>
<div className="grid sm:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Registration</p>
<p className="font-medium">{formatDate(domain.creation_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Last Updated</p>
<p className="font-medium">{formatDate(domain.updated_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expires</p>
<p className="font-medium">{formatDate(domain.expiry_date)}</p>
</div>
</div>
</div>
{/* Registrant Contact */}
{(domain.registrant_name || domain.registrant_org) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Registrant Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.registrant_name && (
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrant_name}</p>
</div>
)}
{domain.registrant_org && (
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">{domain.registrant_org}</p>
</div>
)}
{domain.registrant_country && (
<div>
<p className="text-sm text-muted-foreground">Country</p>
<p className="font-medium">{domain.registrant_country}</p>
</div>
)}
{(domain.registrant_city || domain.registrant_state) && (
<div>
<p className="text-sm text-muted-foreground">Location</p>
<p className="font-medium">
{[domain.registrant_city, domain.registrant_state].filter(Boolean).join(", ")}
</p>
</div>
)}
</div>
</div>
)}
{/* Abuse Contact */}
{(domain.abuse_email || domain.abuse_phone) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Abuse Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.abuse_email && (
<div>
<p className="text-sm text-muted-foreground">Email</p>
<a href={`mailto:${domain.abuse_email}`} className="font-medium text-primary hover:underline">
{domain.abuse_email}
</a>
</div>
)}
{domain.abuse_phone && (
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<p className="font-medium">{domain.abuse_phone}</p>
</div>
)}
</div>
</div>
)}
{/* Domain Status */}
{domain.status && domain.status !== "unknown" && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4" />
Domain Status
</h4>
<div className="flex flex-wrap gap-2">
{domain.status.split(", ").map((status: string, i: number) => (
<Badge key={i} variant="secondary">{status}</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-muted-foreground">No IP addresses found</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Change History</CardTitle>
<CardDescription>Historical changes to domain information</CardDescription>
<CardTitle>Valuation</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{history?.map((item: any) => (
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
<div className="p-2 bg-muted rounded-lg">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{item.change_type}</p>
<p className="text-sm text-muted-foreground">{item.change_description}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.created_at || item.created).toLocaleString()}
</p>
</div>
</div>
))}
{!history?.length && (
<p className="text-muted-foreground text-center py-8">No history available</p>
)}
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchase Price</span>
<span className="font-medium">${domain.purchase_price || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Current Value</span>
<span className="font-medium">${domain.current_value || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Renewal Cost</span>
<span className="font-medium">${domain.renewal_cost || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"}>{domain.auto_renew ? "Yes" : "No"}</Badge>
</div>
</CardContent>
</Card>
<DomainDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
domain={domain}
isEdit
/>
</div>
{/* Notes */}
{domain.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
</CardContent>
</Card>
)}
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>DNS Records</CardTitle>
<CardDescription>Name servers, mail exchangers, and text records</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Nameservers */}
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
Nameservers
<Badge variant="secondary" className="ml-2">
{domain.name_servers?.length || 0}
</Badge>
</h4>
<div className="space-y-1">
{domain.name_servers?.map((ns: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">NS</Badge>
<code className="text-sm">{ns}</code>
</div>
))}
{!domain.name_servers?.length && <p className="text-muted-foreground text-sm">No nameservers found</p>}
</div>
</div>
{/* MX Records */}
{domain.mx_records && domain.mx_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Mail className="h-4 w-4" />
Mail Servers (MX)
<Badge variant="secondary" className="ml-2">
{domain.mx_records.length}
</Badge>
</h4>
<div className="space-y-1">
{domain.mx_records?.map((mx: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">MX</Badge>
<code className="text-sm">{mx}</code>
</div>
))}
</div>
</div>
)}
{/* TXT Records */}
{domain.txt_records && domain.txt_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT Records
<Badge variant="secondary" className="ml-2">
{domain.txt_records.length}
</Badge>
</h4>
<div className="space-y-1">
{domain.txt_records?.map((txt: string, i: number) => (
<div key={i} className="flex items-start gap-2">
<Badge variant="outline">TXT</Badge>
<code className="text-sm break-all">{txt}</code>
</div>
))}
</div>
</div>
)}
{/* DNSSEC */}
{domain.dnssec && (
<div>
<h4 className="text-sm font-medium mb-2">DNSSEC</h4>
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}>{domain.dnssec}</Badge>
</div>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>SSL Certificate Details</CardTitle>
<CardDescription>Certificate information and validity</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{domain.ssl_valid_to ? (
<>
{/* Validity */}
<div className="grid sm:grid-cols-2 gap-4">
<InfoCard title="Valid From" value={formatDate(domain.ssl_valid_from)} icon={Calendar} />
<InfoCard
title="Valid Until"
value={formatDate(domain.ssl_valid_to)}
subtitle={formatDays(domain.ssl_days_until)}
icon={Shield}
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/>
</div>
{/* Issuer & Subject */}
<div className="space-y-4">
<div className="flex items-start gap-3">
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Issuer</p>
<p className="font-medium">{domain.ssl_issuer || "Unknown"}</p>
{domain.ssl_issuer_country && (
<p className="text-sm text-muted-foreground">Country: {domain.ssl_issuer_country}</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Subject</p>
<p className="font-medium">{domain.ssl_subject || "Unknown"}</p>
</div>
</div>
</div>
{/* Technical Details */}
<div className="grid sm:grid-cols-2 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-muted-foreground mb-1">Key Size</p>
<p className="font-medium">{domain.ssl_key_size ? `${domain.ssl_key_size} bits` : "Unknown"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">Signature Algorithm</p>
<p className="font-medium">{domain.ssl_signature_algo || "Unknown"}</p>
</div>
{domain.ssl_fingerprint && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground mb-1">Fingerprint</p>
<code className="text-sm break-all">{domain.ssl_fingerprint}</code>
</div>
)}
</div>
</>
) : (
<div className="text-center py-8">
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No SSL certificate information available</p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>WHOIS Information</CardTitle>
<CardDescription>Domain registration details</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Registrar */}
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Building className="h-4 w-4" />
Registrar
</h4>
<div className="grid sm:grid-cols-2 gap-2">
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrar_name || "Unknown"}</p>
</div>
{domain.registrar_id && (
<div>
<p className="text-sm text-muted-foreground">IANA ID</p>
<p className="font-medium">{domain.registrar_id}</p>
</div>
)}
{domain.registry_domain_id && (
<div className="sm:col-span-2">
<p className="text-sm text-muted-foreground">Registry Domain ID</p>
<p className="font-medium">{domain.registry_domain_id}</p>
</div>
)}
</div>
</div>
{/* Important Dates */}
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
Important Dates
</h4>
<div className="grid sm:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Registration</p>
<p className="font-medium">{formatDate(domain.creation_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Last Updated</p>
<p className="font-medium">{formatDate(domain.updated_date)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Expires</p>
<p className="font-medium">{formatDate(domain.expiry_date)}</p>
</div>
</div>
</div>
{/* Registrant Contact */}
{(domain.registrant_name || domain.registrant_org) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Registrant Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.registrant_name && (
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="font-medium">{domain.registrant_name}</p>
</div>
)}
{domain.registrant_org && (
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">{domain.registrant_org}</p>
</div>
)}
{domain.registrant_country && (
<div>
<p className="text-sm text-muted-foreground">Country</p>
<p className="font-medium">{domain.registrant_country}</p>
</div>
)}
{(domain.registrant_city || domain.registrant_state) && (
<div>
<p className="text-sm text-muted-foreground">Location</p>
<p className="font-medium">
{[domain.registrant_city, domain.registrant_state].filter(Boolean).join(", ")}
</p>
</div>
)}
</div>
</div>
)}
{/* Abuse Contact */}
{(domain.abuse_email || domain.abuse_phone) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Abuse Contact
</h4>
<div className="grid sm:grid-cols-2 gap-2">
{domain.abuse_email && (
<div>
<p className="text-sm text-muted-foreground">Email</p>
<a href={`mailto:${domain.abuse_email}`} className="font-medium text-primary hover:underline">
{domain.abuse_email}
</a>
</div>
)}
{domain.abuse_phone && (
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<p className="font-medium">{domain.abuse_phone}</p>
</div>
)}
</div>
</div>
)}
{/* Domain Status */}
{domain.status && domain.status !== "unknown" && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4" />
Domain Status
</h4>
<div className="flex flex-wrap gap-2">
{domain.status.split(", ").map((status: string, i: number) => (
<Badge key={i} variant="secondary">
{status}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Change History</CardTitle>
<CardDescription>Historical changes to domain information</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{history?.map((item: DomainHistory) => (
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
<div className="p-2 bg-muted rounded-lg">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{item.change_type}</p>
<p className="text-sm text-muted-foreground">{item.change_description}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.created_at || item.created).toLocaleString()}
</p>
</div>
</div>
))}
{!history?.length && <p className="text-muted-foreground text-center py-8">No history available</p>}
</div>
</CardContent>
</Card>
<DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
+79 -91
View File
@@ -7,14 +7,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -32,8 +32,10 @@ import {
PlayIcon,
TrendingUp,
TrendingDown,
type LucideIcon,
} from "lucide-react"
import {
type Heartbeat,
getMonitor,
getMonitorStats,
getMonitorHeartbeats,
@@ -41,13 +43,18 @@ import {
pauseMonitor,
resumeMonitor,
deleteMonitor,
updateMonitor,
getMonitorTypeLabel,
formatUptime,
formatPing,
} from "@/lib/monitors"
import { formatDate } from "@/lib/domains"
import { getStatusPages, createStatusPage } from "@/lib/statuspages"
import {
addMonitorToStatusPage,
createStatusPage,
getStatusPageMonitors,
getStatusPages,
removeMonitorFromStatusPage,
} from "@/lib/statuspages"
import {
Bar,
XAxis,
@@ -64,6 +71,8 @@ import { Link, navigate } from "@/components/router"
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
import { cn } from "@/lib/utils"
type HeartbeatRow = Heartbeat & { timestamp?: string }
// Status badge component
function StatusBadge({ status }: { status: string }) {
const configs = {
@@ -97,7 +106,7 @@ function StatCard({
}: {
title: string
value: string
icon: any
icon: LucideIcon
subtitle?: string
trend?: "up" | "down" | "neutral"
className?: string
@@ -161,6 +170,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
})
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-heartbeats", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-stats", id] })
},
})
@@ -191,10 +201,31 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
queryFn: () => getStatusPages(),
})
const { data: linkedStatusPageMonitors } = useQuery({
queryKey: ["monitor-status-page-links", id, statusPages?.map((page) => page.id).join(",")],
queryFn: async () => {
if (!statusPages?.length) return []
const links = await Promise.all(
statusPages.map(async (page) =>
(await getStatusPageMonitors(page.id)).map((link) => ({ ...link, status_page_id: page.id }))
)
)
return links.flat().filter((link) => link.monitor_id === id)
},
enabled: Boolean(statusPages?.length),
})
const updateStatusPagesMutation = useMutation({
mutationFn: (status_pages: string[]) => updateMonitor(id, { status_pages } as any),
mutationFn: async ({ pageId, linked }: { pageId: string; linked: boolean }) => {
if (linked) {
await removeMonitorFromStatusPage(pageId, id)
} else {
await addMonitorToStatusPage(pageId, { monitor: id })
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-status-page-links", id] })
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
toast({ title: "Status pages updated" })
},
})
@@ -230,7 +261,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
"30d": 30 * 24 * 60 * 60 * 1000,
}
const cutoff = now - (ranges[timeRange] || ranges["24h"])
return heartbeats.filter((h: any) => {
return heartbeats.filter((h: HeartbeatRow) => {
const t = new Date(h.time || h.timestamp).getTime()
return t >= cutoff
})
@@ -242,7 +273,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
return filteredHeartbeats
.slice()
.reverse()
.map((h: any) => ({
.map((h: HeartbeatRow) => ({
time: new Date(h.time || h.timestamp).toLocaleTimeString(),
responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0,
@@ -253,8 +284,8 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
const uptimeStats = useMemo(() => {
if (!heartbeats || !Array.isArray(heartbeats) || heartbeats.length === 0) return null
const total = heartbeats.length
const up = heartbeats.filter((h: any) => h.status === "up").length
const avgResponse = heartbeats.reduce((sum: number, h: any) => sum + (h.ping || 0), 0) / total
const up = heartbeats.filter((h: HeartbeatRow) => h.status === "up").length
const avgResponse = heartbeats.reduce((sum: number, h: HeartbeatRow) => sum + (h.ping || 0), 0) / total
return {
uptime: ((up / total) * 100).toFixed(2),
avgResponse: avgResponse.toFixed(0),
@@ -300,10 +331,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
)}
>
<Globe
className={cn(
"h-6 w-6",
isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500"
)}
className={cn("h-6 w-6", isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500")}
/>
</div>
<div>
@@ -311,13 +339,9 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={monitor.status} />
<Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge>
{monitor.interval && (
<Badge variant="outline">{monitor.interval}s interval</Badge>
)}
{monitor.interval && <Badge variant="outline">{monitor.interval}s interval</Badge>}
</div>
{monitor.url && (
<p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>
)}
{monitor.url && <p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
@@ -371,25 +395,19 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{/* Summary Bar */}
<div className="grid sm:grid-cols-4 gap-4">
<StatCard
title="Uptime (24h)"
value={formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)}
icon={Activity}
/>
<StatCard
title="Uptime (7d)"
value={formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}
icon={Activity}
/>
<StatCard
title="Uptime (30d)"
value={formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}
icon={Activity}
/>
<StatCard title="Uptime (24h)" value={formatUptime(stats?.uptime_percent_24h ?? 0)} icon={Activity} />
<StatCard title="Uptime (7d)" value={formatUptime(stats?.uptime_percent_7d ?? 0)} icon={Activity} />
<StatCard title="Uptime (30d)" value={formatUptime(stats?.uptime_percent_30d ?? 0)} icon={Activity} />
<StatCard
title="Avg Response"
value={uptimeStats ? `${uptimeStats.avgResponse}ms` : "-"}
subtitle={`${uptimeStats?.totalChecks || 0} checks`}
value={
stats?.avg_ping_24h
? `${Math.round(stats.avg_ping_24h)}ms`
: uptimeStats
? `${uptimeStats.avgResponse}ms`
: "-"
}
subtitle={`${stats?.uptime_24h?.total ?? uptimeStats?.totalChecks ?? 0} checks`}
icon={Clock}
/>
</div>
@@ -429,11 +447,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
unit="ms"
/>
<YAxis yAxisId="left" tick={{ fontSize: 12 }} unit="ms" />
<YAxis
yAxisId="right"
orientation="right"
@@ -457,17 +471,9 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
fill="url(#colorResponse)"
name="Response Time (ms)"
/>
<Bar
yAxisId="right"
dataKey="status"
barSize={4}
name="Status"
>
<Bar yAxisId="right" dataKey="status" barSize={4} name="Status">
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.status === 1 ? "#22c55e" : "#ef4444"}
/>
<Cell key={`cell-${index}`} fill={entry.status === 1 ? "#22c55e" : "#ef4444"} />
))}
</Bar>
</ComposedChart>
@@ -521,7 +527,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{statusPages && statusPages.length > 0 ? (
<div className="space-y-2">
{statusPages.map((page) => {
const isLinked = monitor.status_pages?.includes(page.id) || false
const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
return (
<div key={page.id} className="flex items-center justify-between py-1">
<span className="text-sm">{page.name}</span>
@@ -529,31 +535,22 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
variant={isLinked ? "default" : "outline"}
size="sm"
onClick={() => {
const current = monitor.status_pages || []
const next = isLinked
? current.filter((sp) => sp !== page.id)
: [...current, page.id]
updateStatusPagesMutation.mutate({
id: monitor.id,
status_pages: next,
} as any)
pageId: page.id,
linked: isLinked,
})
}}
>
{isLinked ? "Linked" : "Link"}
</Button>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No status pages yet.</p>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsCreateStatusPageOpen(true)}
>
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No status pages yet.</p>
)}
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
Create Status Page
</Button>
</CardContent>
@@ -576,13 +573,11 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</TableRow>
</TableHeader>
<TableBody>
{heartbeats?.slice(0, 50).map((hb: any) => (
{heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => (
<TableRow key={hb.id}>
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell>
<TableCell>
<Badge variant={hb.status === "up" ? "default" : "destructive"}>
{hb.status}
</Badge>
<Badge variant={hb.status === "up" ? "default" : "destructive"}>{hb.status}</Badge>
</TableCell>
<TableCell>{formatPing(hb.ping)}</TableCell>
<TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell>
@@ -606,9 +601,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create Status Page</AlertDialogTitle>
<AlertDialogDescription>
Create a public status page for this monitor.
</AlertDialogDescription>
<AlertDialogDescription>Create a public status page for this monitor.</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
@@ -642,12 +635,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</AlertDialogContent>
</AlertDialog>
)}
<AddMonitorDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
monitor={monitor}
isEdit
/>
<AddMonitorDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} monitor={monitor} isEdit />
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
@@ -17,14 +17,12 @@ import {
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
EyeIcon,
FilterIcon,
LayoutGridIcon,
LayoutListIcon,
PauseIcon,
PlusIcon,
ServerIcon,
Settings2Icon,
XIcon,
} from "lucide-react"
@@ -34,6 +32,7 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
@@ -47,7 +46,6 @@ import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import AlertButton from "../alerts/alert-button"
import { AddSystemDialog } from "../add-system"
import { $router, Link } from "../router"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
@@ -72,7 +70,6 @@ export default function SystemsTable() {
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
const [addSystemDialogOpen, setAddSystemDialogOpen] = useState(false)
const locale = i18n.locale
@@ -137,45 +134,18 @@ export default function SystemsTable() {
const CardHead = useMemo(() => {
return (
<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">
<ServerIcon className="h-5 w-5 text-primary" />
<Trans>All Systems</Trans>
</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Trans>Click on a system to view more information.</Trans>
<span className="text-xs text-muted-foreground">
({upSystemsLength} <ArrowUpIcon className="inline h-3 w-3 text-green-500" />
{downSystemsLength > 0 && (
<>
{" "}
{downSystemsLength}{" "}
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
</>
)}
{pausedSystemsLength > 0 && (
<>
{" "}
{pausedSystemsLength}{" "}
<PauseIcon className="inline h-3 w-3 text-gray-400" />
</>
)}
/ {data.length})
</span>
</CardDescription>
</div>
<Button variant="outline" onClick={() => setAddSystemDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a system to view more information.</Trans>
</CardDescription>
</div>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex gap-2 ms-auto w-full md:w-80">
<div className="relative flex-1">
<Input
placeholder={t`Filter...`}
@@ -203,61 +173,116 @@ export default function SystemsTable() {
<Trans>View</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={(view) => setViewMode(view 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 />
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-s md:divide-y-0">
<div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Layout</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={viewMode}
onValueChange={(view) => setViewMode(view as ViewMode)}
>
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutListIcon className="size-4" />
<Trans>Table</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</div>
{/* Status */}
<DropdownMenuLabel className="flex items-center gap-2">
<FilterIcon className="size-4" />
<Trans>Status</Trans>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={statusFilter} onValueChange={(value) => setStatusFilter(value as StatusFilter)}>
<DropdownMenuRadioItem value="all">
<Trans>All ({data.length})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="up">
<Trans>Up ({upSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="down">
<Trans>Down ({downSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused">
<Trans>Paused ({pausedSystemsLength})</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<FilterIcon className="size-4" />
<Trans>Status</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
>
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
<Trans>All Systems</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
<Trans>Up ({upSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
<Trans>Down ({downSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
<Trans>Paused ({pausedSystemsLength})</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</div>
{/* Columns */}
<DropdownMenuLabel className="flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Columns</Trans>
</DropdownMenuLabel>
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e) => e.preventDefault()}
className="gap-2"
>
{column.columnDef.header?.toString() ?? column.id}
</DropdownMenuCheckboxItem>
))}
<div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<ArrowUpDownIcon className="size-4" />
<Trans>Sort By</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1 pb-1">
{columns.map((column) => {
if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span>
// if current sort column, show sort direction
if (sorting[0]?.id === column.id) {
if (sorting[0]?.desc) {
Icon = <ArrowUpIcon className="me-2 size-4" />
} else {
Icon = <ArrowDownIcon className="me-2 size-4" />
}
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
}}
key={column.id}
>
{Icon}
{/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuItem>
)
})}
</div>
</div>
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{columns
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem>
)
})}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -273,37 +298,32 @@ export default function SystemsTable() {
upSystemsLength,
downSystemsLength,
pausedSystemsLength,
data.length,
filter,
setAddSystemDialogOpen,
])
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>
<AddSystemDialog open={addSystemDialogOpen} setOpen={setAddSystemDialogOpen} />
</>
<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>
)
}