mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
update
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user