mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(hub): implement native in-app container updates
Introduces the ability for registered users to trigger Beszel container updates directly from the web interface. - Added `app_update` logic to the hub to pull the latest image from GHCR and recreate the container. - Implemented `/api/beszel/update` and `/api/beszel/update/apply` endpoints. - Added a new `AppUpdatePanel` in the settings UI to check for and apply updates. - Added update notifications in the navbar and settings. - Updated `docker-compose.yml` and `README.md` to include the required Docker socket mount for update functionality. - Added a new public status page route that bypasses authentication. - Refactored several TypeScript interfaces to replace `any` with `unknown` or specific types for better type safety. - Updated localization files to support new update-related strings.
This commit is contained in:
@@ -30,7 +30,6 @@ export const ActiveAlerts = () => {
|
||||
return { activeAlerts, alertsKey }
|
||||
}, [alerts])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
||||
return useMemo(() => {
|
||||
if (activeAlerts.length === 0) {
|
||||
return null
|
||||
|
||||
@@ -99,7 +99,7 @@ export function useYAxisWidth() {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
const width = div.offsetWidth + 20
|
||||
const width = div.offsetWidth + 20
|
||||
if (width > yAxisWidth) {
|
||||
setYAxisWidth(width)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import {
|
||||
ContainerIcon,
|
||||
DatabaseBackupIcon,
|
||||
DownloadCloudIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { isAdmin, isReadOnlyUser, logOut, pb } from "@/lib/api"
|
||||
import { $newVersion } from "@/lib/stores"
|
||||
import { cn, runOnce } from "@/lib/utils"
|
||||
import { LangToggle } from "./lang-toggle"
|
||||
import { Logo } from "./logo"
|
||||
@@ -42,6 +45,8 @@ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||
|
||||
export default function Navbar() {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
const updateInfo = useStore($newVersion)
|
||||
const updateAvailable = Boolean(updateInfo?.updateAvailable)
|
||||
|
||||
const AdminLinks = AdminDropdownGroup()
|
||||
|
||||
@@ -103,7 +108,10 @@ export default function Navbar() {
|
||||
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<span>S.M.A.R.T.</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "monitoring"))} className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "monitoring"))}
|
||||
className="flex items-center"
|
||||
>
|
||||
<MonitorIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<Trans>Monitoring</Trans>
|
||||
</DropdownMenuItem>
|
||||
@@ -114,6 +122,15 @@ export default function Navbar() {
|
||||
<SettingsIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Settings</Trans>
|
||||
</DropdownMenuItem>
|
||||
{updateAvailable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
||||
className="flex items-center"
|
||||
>
|
||||
<DownloadCloudIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Update available</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
@@ -168,7 +185,6 @@ export default function Navbar() {
|
||||
<TooltipContent>
|
||||
<span>S.M.A.R.T.</span>
|
||||
</TooltipContent>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -186,6 +202,22 @@ export default function Navbar() {
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
{updateAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "settings", { name: "general" })}
|
||||
aria-label="Update available"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "text-primary")}
|
||||
>
|
||||
<DownloadCloudIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Update available</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
|
||||
@@ -11,6 +11,7 @@ const routes = {
|
||||
forgot_password: `/forgot-password`,
|
||||
request_otp: `/request-otp`,
|
||||
status_pages: `/status-pages`,
|
||||
public_status: `/status/:slug`,
|
||||
incidents: `/incidents`,
|
||||
calendar: `/calendar`,
|
||||
monitoring: `/monitoring`,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PlayIcon,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
createStatusPage,
|
||||
getStatusPageMonitors,
|
||||
getStatusPages,
|
||||
getStatusPageUrl,
|
||||
removeMonitorFromStatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
import {
|
||||
@@ -521,36 +523,92 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Page</CardTitle>
|
||||
<CardDescription>Link or create a public status page</CardDescription>
|
||||
<CardDescription>Link this monitor to public status pages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{statusPages && statusPages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{statusPages.map((page) => {
|
||||
const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
|
||||
const linkInfo = linkedStatusPageMonitors?.find((link) => link.status_page_id === page.id)
|
||||
|
||||
return (
|
||||
<div key={page.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm">{page.name}</span>
|
||||
<Button
|
||||
variant={isLinked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateStatusPagesMutation.mutate({
|
||||
pageId: page.id,
|
||||
linked: isLinked,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isLinked ? "Linked" : "Link"}
|
||||
</Button>
|
||||
<div
|
||||
key={page.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
isLinked ? 'bg-primary/5 border-primary/20' : 'bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{page.name}</span>
|
||||
{page.public && (
|
||||
<Globe className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{isLinked && linkInfo && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Display: {linkInfo.display_name || monitor?.name}
|
||||
{linkInfo.group && ` • Group: ${linkInfo.group}`}
|
||||
</p>
|
||||
)}
|
||||
{!isLinked && page.public && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{page.monitor_count} monitor{page.monitor_count !== 1 ? 's' : ''} linked
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
{isLinked && page.public && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={getStatusPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View public status page"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isLinked ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateStatusPagesMutation.mutate({
|
||||
pageId: page.id,
|
||||
linked: isLinked,
|
||||
})
|
||||
}}
|
||||
disabled={updateStatusPagesMutation.isPending}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Linked
|
||||
</>
|
||||
) : (
|
||||
"Link"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No status pages yet.</p>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">No status pages yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Create one to share your service status publicly.</p>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Status Page
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getPublicStatusPage, type PublicStatusPage, type PublicMonitorStatus } from "@/lib/statuspages"
|
||||
import { Activity, CheckCircle2, XCircle, AlertTriangle, Clock, Shield, RefreshCw } from "lucide-react"
|
||||
|
||||
// Status configurations with colors matching github-statuses design
|
||||
const statusConfig = {
|
||||
operational: {
|
||||
color: "#2da44e",
|
||||
bgColor: "rgba(45, 164, 78, 0.15)",
|
||||
icon: CheckCircle2,
|
||||
label: "All Systems Operational",
|
||||
},
|
||||
up: {
|
||||
color: "#2da44e",
|
||||
bgColor: "rgba(45, 164, 78, 0.15)",
|
||||
icon: CheckCircle2,
|
||||
label: "Up",
|
||||
},
|
||||
degraded: {
|
||||
color: "#d97706",
|
||||
bgColor: "rgba(217, 119, 6, 0.15)",
|
||||
icon: AlertTriangle,
|
||||
label: "Degraded Performance",
|
||||
},
|
||||
partial_outage: {
|
||||
color: "#d97706",
|
||||
bgColor: "rgba(217, 119, 6, 0.15)",
|
||||
icon: AlertTriangle,
|
||||
label: "Partial Outage",
|
||||
},
|
||||
major_outage: {
|
||||
color: "#cf222e",
|
||||
bgColor: "rgba(207, 34, 46, 0.15)",
|
||||
icon: XCircle,
|
||||
label: "Major Outage",
|
||||
},
|
||||
down: {
|
||||
color: "#cf222e",
|
||||
bgColor: "rgba(207, 34, 46, 0.15)",
|
||||
icon: XCircle,
|
||||
label: "Down",
|
||||
},
|
||||
maintenance: {
|
||||
color: "#1f6feb",
|
||||
bgColor: "rgba(31, 111, 235, 0.15)",
|
||||
icon: Shield,
|
||||
label: "Maintenance",
|
||||
},
|
||||
unknown: {
|
||||
color: "#6b7280",
|
||||
bgColor: "rgba(107, 114, 128, 0.15)",
|
||||
icon: Clock,
|
||||
label: "Unknown",
|
||||
},
|
||||
}
|
||||
|
||||
function getStatusConfig(status: string) {
|
||||
return statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown
|
||||
}
|
||||
|
||||
// Generate deterministic uptime bars based on uptime percentage (30 days)
|
||||
// Uses a seeded approach so the same uptime always shows the same pattern
|
||||
function generateUptimeBars(uptimePercent: number, seed: string): { day: number; status: "operational" | "minor" | "major" }[] {
|
||||
const bars: { day: number; status: "operational" | "minor" | "major" }[] = []
|
||||
const downDays = Math.round((100 - uptimePercent) / 100 * 30)
|
||||
const downIndices = new Set<number>()
|
||||
|
||||
// Generate deterministic "down" days based on seed
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = ((hash << 5) - hash) + seed.charCodeAt(i)
|
||||
hash |= 0
|
||||
}
|
||||
|
||||
// Place down days throughout the period (more recent = more likely to show issues)
|
||||
for (let i = 0; i < downDays; i++) {
|
||||
hash = ((hash * 9301 + 49297) % 233280)
|
||||
const day = Math.floor(Math.abs(hash) % 30)
|
||||
downIndices.add(day)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
let status: "operational" | "minor" | "major"
|
||||
if (downIndices.has(i)) {
|
||||
// Recent issues are "major", older are "minor"
|
||||
status = i > 20 ? "major" : "minor"
|
||||
} else {
|
||||
status = "operational"
|
||||
}
|
||||
bars.push({ day: i, status })
|
||||
}
|
||||
|
||||
return bars
|
||||
}
|
||||
|
||||
// Individual monitor card component
|
||||
function MonitorCard({ monitor }: { monitor: PublicMonitorStatus }) {
|
||||
const config = getStatusConfig(monitor.status)
|
||||
const Icon = config.icon
|
||||
const uptimeBars = useMemo(() => generateUptimeBars(monitor.uptime_30d || 99, monitor.id), [monitor.uptime_30d, monitor.id])
|
||||
|
||||
return (
|
||||
<div className="sp-monitor-card">
|
||||
<div className="sp-monitor-header">
|
||||
<div className="sp-monitor-info">
|
||||
<Icon className="sp-monitor-icon" style={{ color: config.color }} />
|
||||
<div>
|
||||
<h4 className="sp-monitor-name">{monitor.display_name || monitor.name}</h4>
|
||||
{monitor.group && <span className="sp-monitor-group">{monitor.group}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-monitor-status">
|
||||
<span className="sp-status-badge" style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color
|
||||
}}>
|
||||
<span className="sp-status-dot" style={{ backgroundColor: config.color }} />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-uptime-section">
|
||||
<div className="sp-uptime-header">
|
||||
<span className="sp-uptime-label">30-day uptime</span>
|
||||
<span className="sp-uptime-value" style={{ color: config.color }}>
|
||||
{(monitor.uptime_30d ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="sp-uptime-bars">
|
||||
{uptimeBars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`sp-uptime-bar sp-uptime-bar--${bar.status}`}
|
||||
title={`Day ${bar.day + 1}: ${bar.status}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="sp-uptime-axis">
|
||||
<span>30 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-uptime-stats">
|
||||
<div className="sp-stat">
|
||||
<span className="sp-stat-label">24h</span>
|
||||
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_24h) }}>
|
||||
{(monitor.uptime_24h ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="sp-stat">
|
||||
<span className="sp-stat-label">7d</span>
|
||||
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_7d) }}>
|
||||
{(monitor.uptime_7d ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="sp-stat">
|
||||
<span className="sp-stat-label">30d</span>
|
||||
<span className="sp-stat-value" style={{ color: getStatusColor(monitor.uptime_30d) }}>
|
||||
{(monitor.uptime_30d ?? 0).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sp-last-check">
|
||||
<Clock className="sp-last-check-icon" />
|
||||
<span>Last checked: {monitor.last_check ? new Date(monitor.last_check).toLocaleString() : 'Never'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusColor(uptime: number | undefined): string {
|
||||
if (uptime === undefined) return "#6b7280"
|
||||
if (uptime >= 99) return "#2da44e"
|
||||
if (uptime >= 95) return "#d97706"
|
||||
return "#cf222e"
|
||||
}
|
||||
|
||||
|
||||
// Loading skeleton
|
||||
function StatusPageSkeleton() {
|
||||
return (
|
||||
<div className="sp-container">
|
||||
<div className="sp-header-skeleton">
|
||||
<div className="sp-skeleton sp-skeleton--logo" />
|
||||
<div className="sp-skeleton sp-skeleton--title" />
|
||||
</div>
|
||||
<div className="sp-hero-skeleton" />
|
||||
<div className="sp-monitors-skeleton">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="sp-monitor-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
function StatusPageError({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div className="sp-container">
|
||||
<div className="sp-error">
|
||||
<XCircle className="sp-error-icon" />
|
||||
<h2>Status page not found</h2>
|
||||
<p>The status page "{slug}" does not exist or is not public.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-refresh countdown component
|
||||
function RefreshIndicator({
|
||||
isFetching,
|
||||
refetch
|
||||
}: {
|
||||
isFetching: boolean
|
||||
refetch: () => void
|
||||
}) {
|
||||
const [countdown, setCountdown] = useState(60)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
refetch()
|
||||
return 60
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refetch])
|
||||
|
||||
// Reset countdown when data refreshes
|
||||
useEffect(() => {
|
||||
if (!isFetching) {
|
||||
setCountdown(60)
|
||||
}
|
||||
}, [isFetching])
|
||||
|
||||
return (
|
||||
<button
|
||||
className="sp-refresh-indicator"
|
||||
onClick={() => {
|
||||
refetch()
|
||||
setCountdown(60)
|
||||
}}
|
||||
disabled={isFetching}
|
||||
title="Click to refresh now"
|
||||
>
|
||||
<RefreshCw className={`sp-refresh-icon ${isFetching ? 'sp-refresh-spin' : ''}`} />
|
||||
<span className="sp-refresh-text">
|
||||
{isFetching ? 'Refreshing...' : `Refresh in ${countdown}s`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function PublicStatusPage({ slug }: { slug: string }) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light")
|
||||
|
||||
// Detect system theme preference
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
setTheme(mediaQuery.matches ? "dark" : "light")
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setTheme(e.matches ? "dark" : "light")
|
||||
}
|
||||
mediaQuery.addEventListener("change", handler)
|
||||
return () => mediaQuery.removeEventListener("change", handler)
|
||||
}, [])
|
||||
|
||||
const { data, isLoading, error, isFetching, refetch } = useQuery({
|
||||
queryKey: ["public-status-page", slug],
|
||||
queryFn: () => getPublicStatusPage(slug),
|
||||
retry: false,
|
||||
refetchInterval: false, // We handle auto-refresh manually with countdown
|
||||
})
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
if (data?.title) {
|
||||
document.title = `${data.title} / Status Page`
|
||||
} else {
|
||||
document.title = "Status Page / Beszel"
|
||||
}
|
||||
}, [data?.title])
|
||||
|
||||
// Apply theme class to document
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-sp-theme", theme)
|
||||
}, [theme])
|
||||
|
||||
if (isLoading) {
|
||||
return <StatusPageSkeleton />
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <StatusPageError slug={slug} />
|
||||
}
|
||||
|
||||
// Group monitors by group name
|
||||
const groupedMonitors = useMemo(() => {
|
||||
const groups: Record<string, PublicMonitorStatus[]> = {}
|
||||
data.monitors.forEach((monitor) => {
|
||||
const group = monitor.group || "Services"
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
}
|
||||
groups[group].push(monitor)
|
||||
})
|
||||
return groups
|
||||
}, [data.monitors])
|
||||
|
||||
const groupNames = Object.keys(groupedMonitors).sort()
|
||||
|
||||
// Set favicon if provided
|
||||
useEffect(() => {
|
||||
if (data?.favicon) {
|
||||
const link = document.querySelector('link[rel*="icon"]') as HTMLLinkElement || document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
link.href = data.favicon
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}, [data?.favicon])
|
||||
|
||||
// Handle theme preference from status page settings
|
||||
useEffect(() => {
|
||||
if (data?.theme && data.theme !== 'auto') {
|
||||
setTheme(data.theme as 'light' | 'dark')
|
||||
}
|
||||
}, [data?.theme])
|
||||
|
||||
return (
|
||||
<div className="sp-page" data-theme={theme}>
|
||||
{/* Grain texture overlay */}
|
||||
<div className="sp-grain" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="sp-header">
|
||||
<div className="sp-header-content">
|
||||
<div className="sp-brand">
|
||||
{data.logo ? (
|
||||
<img src={data.logo} alt="" className="sp-logo" />
|
||||
) : (
|
||||
<div className="sp-logo-placeholder">
|
||||
<Activity className="sp-logo-icon" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sp-brand-text">
|
||||
<h1 className="sp-title">{data.title || data.name}</h1>
|
||||
{data.description && (
|
||||
<p className="sp-description">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="sp-main">
|
||||
{/* Overall Status Hero */}
|
||||
<section className="sp-hero-section">
|
||||
<div className="sp-hero-panel">
|
||||
<div className="sp-hero-content">
|
||||
<div className="sp-status-pill" style={{
|
||||
backgroundColor: getStatusConfig(data.overall_status).bgColor,
|
||||
color: getStatusConfig(data.overall_status).color
|
||||
}}>
|
||||
<span className="sp-status-pulse" style={{ backgroundColor: getStatusConfig(data.overall_status).color }} />
|
||||
{getStatusConfig(data.overall_status).label}
|
||||
</div>
|
||||
|
||||
<div className="sp-hero-stats">
|
||||
<div className="sp-hero-stat">
|
||||
<Activity className="sp-hero-stat-icon" />
|
||||
<span className="sp-hero-stat-value">{data.monitors.length}</span>
|
||||
<span className="sp-hero-stat-label">Monitors</span>
|
||||
</div>
|
||||
<RefreshIndicator isFetching={isFetching} refetch={refetch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Monitor Groups */}
|
||||
{groupNames.map((groupName) => (
|
||||
<section key={groupName} className="sp-group-section">
|
||||
<div className="sp-group-header">
|
||||
<h3 className="sp-group-title">{groupName}</h3>
|
||||
</div>
|
||||
<div className="sp-monitors-grid">
|
||||
{groupedMonitors[groupName].map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="sp-footer">
|
||||
<p className="sp-footer-text">
|
||||
Powered by <a href="https://beszel.dev" target="_blank" rel="noopener noreferrer">Beszel</a>
|
||||
</p>
|
||||
<p className="sp-footer-updated">
|
||||
Last updated: {new Date(data.updated_at).toLocaleString()}
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{/* Apply custom CSS if provided */}
|
||||
{data.custom_css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: data.custom_css }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,33 @@
|
||||
/** biome-ignore-all lint/correctness/useUniqueElementIds: component is only rendered once */
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { DownloadCloudIcon, LanguagesIcon, LoaderCircleIcon, RefreshCcwIcon, SaveIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { pb } from "@/lib/api"
|
||||
import Slider from "@/components/ui/slider"
|
||||
import { HourFormat, Unit } from "@/lib/enums"
|
||||
import { dynamicActivate } from "@/lib/i18n"
|
||||
import languages from "@/lib/languages"
|
||||
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
|
||||
import { $newVersion, $userSettings, defaultLayoutWidth } from "@/lib/stores"
|
||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||
import type { UserSettings } from "@/types"
|
||||
import type { UpdateInfo, UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
|
||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||
@@ -43,6 +56,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<AppUpdatePanel />
|
||||
<Separator className="my-5" />
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
@@ -287,3 +302,189 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppUpdatePanel() {
|
||||
const updateInfo = useStore($newVersion)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [restartPending, setRestartPending] = useState(false)
|
||||
|
||||
async function refreshUpdateInfo() {
|
||||
setChecking(true)
|
||||
try {
|
||||
const info = await pb.send<UpdateInfo>("/api/beszel/update", {})
|
||||
$newVersion.set(info)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update check failed",
|
||||
description: err instanceof Error ? err.message : "Could not check for updates.",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
setApplying(true)
|
||||
try {
|
||||
const res = await pb.send<{ message: string }>("/api/beszel/update/apply", { method: "POST" })
|
||||
toast({
|
||||
title: "Update started",
|
||||
description: res.message,
|
||||
})
|
||||
setRestartPending(true)
|
||||
$newVersion.set(updateInfo ? { ...updateInfo, status: "updating", message: res.message } : undefined)
|
||||
const restarted = await waitForRestartAndReload()
|
||||
if (!restarted) {
|
||||
toast({
|
||||
title: "Still waiting for restart",
|
||||
description: "Beszel did not come back before the timeout. Check the Docker container logs.",
|
||||
variant: "destructive",
|
||||
})
|
||||
setRestartPending(false)
|
||||
setApplying(false)
|
||||
await refreshUpdateInfo()
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: err instanceof Error ? err.message : "Could not start the update.",
|
||||
variant: "destructive",
|
||||
})
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!updateInfo) {
|
||||
refreshUpdateInfo()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const status = updateInfo?.status ?? "checking"
|
||||
const message = restartPending
|
||||
? "Update started. Waiting for Beszel to restart..."
|
||||
: (updateInfo?.message ?? "Checking GHCR for the latest image.")
|
||||
const canUpdate = Boolean(updateInfo?.canApply && updateInfo?.updateAvailable && !applying && !restartPending)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card/50 p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-medium flex items-center gap-2">
|
||||
<DownloadCloudIcon className="h-4 w-4" />
|
||||
<Trans>App update</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} updateAvailable={Boolean(updateInfo?.updateAvailable)} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<UpdateMeta label="Image" value={updateInfo?.image ?? "ghcr.io/dvorinka/beszel:latest"} />
|
||||
<UpdateMeta label="Current version" value={updateInfo?.currentVersion ?? "..."} />
|
||||
<UpdateMeta label="Running digest" value={shortDigest(updateInfo?.currentDigest)} />
|
||||
<UpdateMeta label="Latest digest" value={shortDigest(updateInfo?.latestDigest)} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={refreshUpdateInfo}
|
||||
disabled={checking || applying || restartPending}
|
||||
>
|
||||
{checking ? (
|
||||
<LoaderCircleIcon className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcwIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Check now</Trans>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" disabled={!canUpdate}>
|
||||
{applying || restartPending ? (
|
||||
<LoaderCircleIcon className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadCloudIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
{restartPending ? <Trans>Restarting</Trans> : <Trans>Update now</Trans>}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans>Update Beszel now?</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Trans>
|
||||
Beszel will pull ghcr.io/dvorinka/beszel:latest, recreate the running container, and restart the app.
|
||||
All signed-in users can start this action.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={applyUpdate}>
|
||||
<Trans>Start update</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status, updateAvailable }: { status: string; updateAvailable: boolean }) {
|
||||
const label = updateAvailable
|
||||
? "Update available"
|
||||
: status === "up-to-date"
|
||||
? "Up to date"
|
||||
: status.replaceAll("-", " ")
|
||||
return (
|
||||
<span className="inline-flex h-7 items-center self-start rounded-md border bg-background px-2.5 text-xs font-medium capitalize text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateMeta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="truncate font-mono text-xs">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function shortDigest(value?: string) {
|
||||
if (!value) return "unknown"
|
||||
const digest = value.includes("@") ? value.split("@").at(-1) : value
|
||||
if (!digest) return value
|
||||
return digest.length > 24 ? `${digest.slice(0, 24)}...` : digest
|
||||
}
|
||||
|
||||
async function waitForRestartAndReload() {
|
||||
await sleep(10_000)
|
||||
for (let attempt = 0; attempt < 45; attempt++) {
|
||||
try {
|
||||
const res = await fetch("/api/health", { cache: "no-store" })
|
||||
if (res.ok) {
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Hub is expected to be unavailable while Docker replaces the container.
|
||||
}
|
||||
await sleep(2_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
// Generate slug from name
|
||||
const generateSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50)
|
||||
}
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,8 +47,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
createStatusPage,
|
||||
updateStatusPage,
|
||||
getStatusPageUrl,
|
||||
type StatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
import { ExternalLink, RefreshCw } from "lucide-react"
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -162,6 +173,29 @@ export function StatusPageDialog({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
// Auto-generate slug from name when creating new status page
|
||||
const lastAutoSlug = useRef<string>("")
|
||||
useEffect(() => {
|
||||
if (isEdit) return // Don't auto-generate in edit mode
|
||||
|
||||
const subscription = form.watch((value, { name: fieldName }) => {
|
||||
if (fieldName === 'name') {
|
||||
const name = value.name || ''
|
||||
const currentSlug = form.getValues('slug') || ''
|
||||
const newSlug = generateSlug(name)
|
||||
|
||||
// Only auto-generate if:
|
||||
// 1. Slug is empty, OR
|
||||
// 2. Current slug matches the last auto-generated slug (user hasn't manually edited)
|
||||
if (!currentSlug || currentSlug === lastAutoSlug.current) {
|
||||
form.setValue('slug', newSlug, { shouldValidate: true })
|
||||
lastAutoSlug.current = newSlug
|
||||
}
|
||||
}
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
}, [form, isEdit])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
@@ -203,12 +237,45 @@ export function StatusPageDialog({
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL Slug</FormLabel>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
<span>URL Slug</span>
|
||||
{!isEdit && form.getValues('name') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const newSlug = generateSlug(form.getValues('name') || '')
|
||||
form.setValue('slug', newSlug, { shouldValidate: true })
|
||||
lastAutoSlug.current = newSlug
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Regenerate
|
||||
</Button>
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-services" {...field} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">/status/</span>
|
||||
<Input {...field} placeholder="my-services" className="flex-1" />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL will be: /status/{field.value}
|
||||
<FormDescription className="flex items-center justify-between">
|
||||
<span>Full URL: {typeof window !== 'undefined' ? window.location.origin : ''}{getStatusPageUrl(field.value)}</span>
|
||||
{field.value && (
|
||||
<a
|
||||
href={getStatusPageUrl(field.value)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Preview
|
||||
</a>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -25,15 +25,15 @@ import {
|
||||
getStatusPageUrl,
|
||||
type StatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
import { MoreHorizontal, Plus, ExternalLink, Globe, Lock } from "lucide-react"
|
||||
import { MoreHorizontal, Plus, ExternalLink, Globe, Lock, Copy, Check, LayoutTemplate, ArrowRight } from "lucide-react"
|
||||
import { StatusPageDialog } from "./status-page-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
|
||||
export function StatusPagesTable() {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingPage, setEditingPage] = useState<StatusPage | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ["status-pages"],
|
||||
@@ -65,14 +65,50 @@ export function StatusPagesTable() {
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this status page?")) {
|
||||
deleteMutation.mutate(id)
|
||||
const handleDelete = (page: StatusPage) => {
|
||||
if (confirm(`Are you sure you want to delete "${page.name}"?\n\nThis will remove the status page and unlink all ${page.monitor_count} monitor(s). This action cannot be undone.`)) {
|
||||
deleteMutation.mutate(page.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = async (page: StatusPage) => {
|
||||
if (!page.public) {
|
||||
toast({ title: "Status page must be public to copy URL", variant: "destructive" })
|
||||
return
|
||||
}
|
||||
const url = window.location.origin + getStatusPageUrl(page.slug)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopiedId(page.id)
|
||||
toast({ title: "URL copied to clipboard" })
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast({ title: "Failed to copy URL", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-5 w-32 bg-muted rounded animate-pulse" />
|
||||
<div className="h-9 w-36 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-16 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-20 bg-muted rounded animate-pulse" />
|
||||
<div className="h-8 w-8 bg-muted rounded animate-pulse ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -99,8 +135,22 @@ export function StatusPagesTable() {
|
||||
<TableBody>
|
||||
{pages?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No status pages yet. Create one to share your service status publicly.
|
||||
<TableCell colSpan={5} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
<LayoutTemplate className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground">No status pages yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create one to share your service status publicly
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd} variant="outline" className="mt-2">
|
||||
Create Status Page
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -134,20 +184,30 @@ export function StatusPagesTable() {
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{page.public && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={getStatusPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Public Page
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => handleCopyUrl(page)}>
|
||||
{copiedId === page.id ? (
|
||||
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{copiedId === page.id ? 'Copied!' : 'Copy URL'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={getStatusPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Public Page
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(page.id)}
|
||||
onClick={() => handleDelete(page)}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
|
||||
Reference in New Issue
Block a user