mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 10:46:16 +01:00
add quiet hours to silence alerts during specific time periods (#265)
This commit is contained in:
@@ -30,11 +30,12 @@
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "error",
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"reportUnnecessaryDependencies": false
|
||||
}
|
||||
},
|
||||
"useUniqueElementIds": "off",
|
||||
"noUnusedVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { toast } from "@/components/ui/use-toast"
|
||||
import { isAdmin, pb } from "@/lib/api"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import { QuietHours } from "./quiet-hours"
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
@@ -120,19 +121,32 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 shrink-0"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add URL</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{webhooks.length > 0 && (
|
||||
<div className="grid gap-2.5" id="webhooks">
|
||||
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 flex items-center gap-1"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
||||
<Trans>Add URL</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<QuietHours />
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
@@ -194,7 +202,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-muted/40 p-2 md:p-3">
|
||||
<Card className="bg-table-header p-2 md:p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="url"
|
||||
|
||||
530
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
530
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
ActivityIcon,
|
||||
PenSquareIcon,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { formatShortDate } from "@/lib/utils"
|
||||
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
||||
|
||||
export function QuietHours() {
|
||||
const [data, setData] = useState<QuietHoursRecord[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
||||
const { toast } = useToast()
|
||||
const systems = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = {
|
||||
expand: "system",
|
||||
fields: "id,user,system,type,start,end,expand.system.name",
|
||||
}
|
||||
// Initial load
|
||||
pb.collection<QuietHoursRecord>("quiet_hours")
|
||||
.getList(0, 200, {
|
||||
...pbOptions,
|
||||
sort: "system",
|
||||
})
|
||||
.then(({ items }) => setData(items))
|
||||
|
||||
// Subscribe to changes
|
||||
;(async () => {
|
||||
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||
"*",
|
||||
(e) => {
|
||||
if (e.action === "create") {
|
||||
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||
}
|
||||
if (e.action === "update") {
|
||||
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
||||
}
|
||||
if (e.action === "delete") {
|
||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||
}
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
})()
|
||||
// Unsubscribe on unmount
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await pb.collection("quiet_hours").delete(id)
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: (e as Error).message || "Failed to delete quiet hours.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (record: QuietHoursRecord) => {
|
||||
setEditingRecord(record)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingRecord(null)
|
||||
}
|
||||
|
||||
const formatDateTime = (record: QuietHoursRecord) => {
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, show only time
|
||||
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
return `${startTime} - ${endTime}`
|
||||
}
|
||||
// For one-time windows, show full date and time
|
||||
const start = formatShortDate(record.start)
|
||||
const end = formatShortDate(record.end)
|
||||
return `${start} - ${end}`
|
||||
}
|
||||
|
||||
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => {
|
||||
const now = new Date()
|
||||
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, check if current time is within the window
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
// Get current time in local timezone
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
||||
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
||||
|
||||
// Convert UTC to local time offset
|
||||
const offset = now.getTimezoneOffset()
|
||||
const localStartMinutes = (startMinutes - offset + 1440) % 1440
|
||||
const localEndMinutes = (endMinutes - offset + 1440) % 1440
|
||||
|
||||
// Handle cases where window spans midnight
|
||||
if (localStartMinutes <= localEndMinutes) {
|
||||
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future"
|
||||
} else {
|
||||
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future"
|
||||
}
|
||||
} else {
|
||||
// For one-time windows
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
if (now >= startDate && now < endDate) {
|
||||
return "active"
|
||||
} else if (now >= endDate) {
|
||||
return "past"
|
||||
} else {
|
||||
return "future"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Quiet hours</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add Quiet Hours</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
||||
</Dialog>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<Trans>System</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4" />
|
||||
<Trans>Type</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
<Trans>State</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4" />
|
||||
<Trans>Schedule</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4 text-right sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{(() => {
|
||||
const state = getWindowState(record)
|
||||
const stateConfig = {
|
||||
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||
future: { label: <Trans>Future</Trans>, variant: "default" as const },
|
||||
}
|
||||
const config = stateConfig[state]
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
||||
<PenSquareIcon className="me-2.5 size-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
||||
function formatDateTimeLocal(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
const hours = String(date.getHours()).padStart(2, "0")
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function QuietHoursDialog({
|
||||
editingRecord,
|
||||
systems,
|
||||
onClose,
|
||||
toast,
|
||||
}: {
|
||||
editingRecord: QuietHoursRecord | null
|
||||
systems: SystemRecord[]
|
||||
onClose: () => void
|
||||
toast: any
|
||||
}) {
|
||||
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
||||
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
||||
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
|
||||
const [startDateTime, setStartDateTime] = useState("")
|
||||
const [endDateTime, setEndDateTime] = useState("")
|
||||
const [startTime, setStartTime] = useState("")
|
||||
const [endTime, setEndTime] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRecord) {
|
||||
setSelectedSystem(editingRecord.system || "")
|
||||
setIsGlobal(!editingRecord.system)
|
||||
setWindowType(editingRecord.type)
|
||||
if (editingRecord.type === "daily") {
|
||||
// Extract time from datetime
|
||||
const start = new Date(editingRecord.start)
|
||||
const end = editingRecord.end ? new Date(editingRecord.end) : null
|
||||
setStartTime(start.toTimeString().slice(0, 5))
|
||||
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
|
||||
} else {
|
||||
// For one-time, format as datetime-local (local time, not UTC)
|
||||
const startDate = new Date(editingRecord.start)
|
||||
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
|
||||
|
||||
setStartDateTime(formatDateTimeLocal(startDate))
|
||||
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
|
||||
}
|
||||
} else {
|
||||
// Reset form with default dates: today at 12pm and 1pm
|
||||
const today = new Date()
|
||||
const noon = new Date(today)
|
||||
noon.setHours(12, 0, 0, 0)
|
||||
const onePm = new Date(today)
|
||||
onePm.setHours(13, 0, 0, 0)
|
||||
|
||||
setSelectedSystem("")
|
||||
setIsGlobal(true)
|
||||
setWindowType("one-time")
|
||||
setStartDateTime(formatDateTimeLocal(noon))
|
||||
setEndDateTime(formatDateTimeLocal(onePm))
|
||||
setStartTime("12:00")
|
||||
setEndTime("13:00")
|
||||
}
|
||||
}, [editingRecord])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
let startValue: string
|
||||
let endValue: string | undefined
|
||||
|
||||
if (windowType === "daily") {
|
||||
// For daily windows, convert local time to UTC
|
||||
// Create a date with the time in local timezone, then convert to UTC
|
||||
const startDate = new Date(`2000-01-01T${startTime}:00`)
|
||||
startValue = startDate.toISOString()
|
||||
|
||||
if (endTime) {
|
||||
const endDate = new Date(`2000-01-01T${endTime}:00`)
|
||||
endValue = endDate.toISOString()
|
||||
}
|
||||
} else {
|
||||
// For one-time windows, use the datetime values
|
||||
startValue = new Date(startDateTime).toISOString()
|
||||
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: pb.authStore.record?.id,
|
||||
system: isGlobal ? undefined : selectedSystem,
|
||||
type: windowType,
|
||||
start: startValue,
|
||||
end: endValue,
|
||||
}
|
||||
|
||||
if (editingRecord) {
|
||||
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
||||
toast({
|
||||
title: t`Updated`,
|
||||
description: t`Quiet hours have been updated.`,
|
||||
})
|
||||
} else {
|
||||
await pb.collection("quiet_hours").create(data)
|
||||
toast({
|
||||
title: t`Created`,
|
||||
description: t`Quiet hours have been created.`,
|
||||
})
|
||||
}
|
||||
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: t`Failed to save quiet hours.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Configure quiet hours where notifications will not be sent.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="global">
|
||||
<Trans>All Systems</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system">
|
||||
<Trans>Specific System</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="system">
|
||||
<Trans>System</Trans>
|
||||
</Label>
|
||||
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
||||
<SelectTrigger id="system">
|
||||
<SelectValue placeholder={t`Select a system`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{systems.map((system) => (
|
||||
<SelectItem key={system.id} value={system.id}>
|
||||
{system.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Hidden input for native form validation */}
|
||||
<input
|
||||
className="sr-only"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={selectedSystem}
|
||||
onChange={() => {}}
|
||||
required={!isGlobal}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">
|
||||
<Trans>Type</Trans>
|
||||
</Label>
|
||||
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one-time">
|
||||
<Trans>One-time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="daily">
|
||||
<Trans>Daily</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{windowType === "one-time" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="start-datetime">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="start-datetime"
|
||||
type="datetime-local"
|
||||
value={startDateTime}
|
||||
onChange={(e) => setStartDateTime(e.target.value)}
|
||||
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="end-datetime">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="end-datetime"
|
||||
type="datetime-local"
|
||||
value={endDateTime}
|
||||
onChange={(e) => setEndDateTime(e.target.value)}
|
||||
min={startDateTime || formatDateTimeLocal(new Date())}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2 grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="start-time">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="start-time"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end-time">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="end-time"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value , value >= 100 ? 1 : 2)} {unit}
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -377,9 +377,9 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
||||
const meterClass = cn(
|
||||
"h-full",
|
||||
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
||||
@@ -401,18 +401,18 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||
}
|
||||
|
||||
const rootDiskPct = sysInfo.dp
|
||||
|
||||
|
||||
// sort extra disks by percentage descending
|
||||
extraFs.sort((a, b) => b[1] - a[1])
|
||||
|
||||
|
||||
function getMeterClass(pct: number) {
|
||||
const threshold = getMeterState(pct)
|
||||
return cn(
|
||||
"h-full",
|
||||
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
)
|
||||
}
|
||||
|
||||
@@ -435,30 +435,30 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
||||
<div className="grid gap-1.5">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
{extraFs.map(([name, pct]) => {
|
||||
return (
|
||||
<div key={name} className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{extraFs.map(([name, pct]) => {
|
||||
return (
|
||||
<div key={name} className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -469,7 +469,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 size-2 rounded-full", className)}
|
||||
// style={{ marginBottom: "-1px" }}
|
||||
// style={{ marginBottom: "-1px" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
14
internal/site/src/types.d.ts
vendored
14
internal/site/src/types.d.ts
vendored
@@ -244,6 +244,20 @@ export interface AlertsHistoryRecord extends RecordModel {
|
||||
resolved?: string | null
|
||||
}
|
||||
|
||||
export interface QuietHoursRecord extends RecordModel {
|
||||
id: string
|
||||
user: string
|
||||
system: string
|
||||
type: "one-time" | "daily"
|
||||
start: string
|
||||
end: string
|
||||
expand?: {
|
||||
system?: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContainerRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
|
||||
Reference in New Issue
Block a user