Compare commits

..

4 Commits

Author SHA1 Message Date
Pavel Pikta
4d05bfdff0 feat: add crossorigin attribute to manifest link (#1457)
Signed-off-by: Pavel Pikta <pavel_pikta@epam.com>
2025-11-26 19:41:54 -05:00
henrygd
0388401a9e change layout of extra disks in all systems table (#1409) 2025-11-25 16:23:48 -05:00
henrygd
162c548010 quiet hours refactoring: change 'future' to 'inactive' 2025-11-24 19:12:35 -05:00
henrygd
888b4a57e5 add quiet hours to silence alerts during specific time periods (#265) 2025-11-24 17:35:28 -05:00
4 changed files with 511 additions and 490 deletions

View File

@@ -48,7 +48,8 @@
}, },
"suspicious": { "suspicious": {
"useAwait": "error", "useAwait": "error",
"noEvolvingTypes": "error" "noEvolvingTypes": "error",
"noArrayIndexKey": "off"
} }
} }
}, },

View File

@@ -2,7 +2,7 @@
<html lang="en" dir="ltr"> <html lang="en" dir="ltr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" /> <link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" /> <link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />

View File

@@ -1,26 +1,30 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { MoreHorizontalIcon, PlusIcon, Trash2Icon, ServerIcon, ClockIcon, CalendarIcon, ActivityIcon, PenSquareIcon } from "lucide-react" import {
MoreHorizontalIcon,
PlusIcon,
Trash2Icon,
ServerIcon,
ClockIcon,
CalendarIcon,
ActivityIcon,
PenSquareIcon,
} from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -33,497 +37,494 @@ import { formatShortDate } from "@/lib/utils"
import type { QuietHoursRecord, SystemRecord } from "@/types" import type { QuietHoursRecord, SystemRecord } from "@/types"
export function QuietHours() { export function QuietHours() {
const [data, setData] = useState<QuietHoursRecord[]>([]) const [data, setData] = useState<QuietHoursRecord[]>([])
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null) const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
const { toast } = useToast() const { toast } = useToast()
const systems = useStore($systems) const systems = useStore($systems)
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | undefined let unsubscribe: (() => void) | undefined
const pbOptions = { const pbOptions = {
expand: "system", expand: "system",
fields: "id,user,system,type,start,end,expand.system.name", fields: "id,user,system,type,start,end,expand.system.name",
} }
// Initial load // Initial load
pb.collection<QuietHoursRecord>("quiet_hours") pb.collection<QuietHoursRecord>("quiet_hours")
.getList(0, 200, { .getList(0, 200, {
...pbOptions, ...pbOptions,
sort: "system", sort: "system",
}) })
.then(({ items }) => setData(items)) .then(({ items }) => setData(items))
// Subscribe to changes // Subscribe to changes
; (async () => { ;(async () => {
unsubscribe = await pb.collection("quiet_hours").subscribe( unsubscribe = await pb.collection("quiet_hours").subscribe(
"*", "*",
(e) => { (e) => {
if (e.action === "create") { if (e.action === "create") {
setData((current) => [e.record as QuietHoursRecord, ...current]) setData((current) => [e.record as QuietHoursRecord, ...current])
} }
if (e.action === "update") { if (e.action === "update") {
setData((current) => setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
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))
if (e.action === "delete") { }
setData((current) => current.filter((r) => r.id !== e.record.id)) },
} pbOptions
}, )
pbOptions })()
) // Unsubscribe on unmount
})() return () => unsubscribe?.()
// Unsubscribe on unmount }, [])
return () => unsubscribe?.()
}, [])
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await pb.collection("quiet_hours").delete(id) await pb.collection("quiet_hours").delete(id)
} catch (e: unknown) { } catch (e: unknown) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t`Error`, title: t`Error`,
description: (e as Error).message || "Failed to delete quiet hours.", description: (e as Error).message || "Failed to delete quiet hours.",
}) })
} }
} }
const openEditDialog = (record: QuietHoursRecord) => { const openEditDialog = (record: QuietHoursRecord) => {
setEditingRecord(record) setEditingRecord(record)
setDialogOpen(true) setDialogOpen(true)
} }
const closeDialog = () => { const closeDialog = () => {
setDialogOpen(false) setDialogOpen(false)
setEditingRecord(null) setEditingRecord(null)
} }
const formatDateTime = (record: QuietHoursRecord) => { const formatDateTime = (record: QuietHoursRecord) => {
if (record.type === "daily") { if (record.type === "daily") {
// For daily windows, show only time // For daily windows, show only time
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
return `${startTime} - ${endTime}` return `${startTime} - ${endTime}`
} }
// For one-time windows, show full date and time // For one-time windows, show full date and time
const start = formatShortDate(record.start) const start = formatShortDate(record.start)
const end = formatShortDate(record.end) const end = formatShortDate(record.end)
return `${start} - ${end}` return `${start} - ${end}`
} }
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => { const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
const now = new Date() const now = new Date()
if (record.type === "daily") { if (record.type === "daily") {
// For daily windows, check if current time is within the window // For daily windows, check if current time is within the window
const startDate = new Date(record.start) const startDate = new Date(record.start)
const endDate = new Date(record.end) const endDate = new Date(record.end)
// Get current time in local timezone // Get current time in local timezone
const currentMinutes = now.getHours() * 60 + now.getMinutes() const currentMinutes = now.getHours() * 60 + now.getMinutes()
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes() const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes() const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
// Convert UTC to local time offset // Convert UTC to local time offset
const offset = now.getTimezoneOffset() const offset = now.getTimezoneOffset()
const localStartMinutes = (startMinutes - offset + 1440) % 1440 const localStartMinutes = (startMinutes - offset + 1440) % 1440
const localEndMinutes = (endMinutes - offset + 1440) % 1440 const localEndMinutes = (endMinutes - offset + 1440) % 1440
// Handle cases where window spans midnight // Handle cases where window spans midnight
if (localStartMinutes <= localEndMinutes) { if (localStartMinutes <= localEndMinutes) {
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future" return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
} else { } else {
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future" return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
} }
} else { } else {
// For one-time windows // For one-time windows
const startDate = new Date(record.start) const startDate = new Date(record.start)
const endDate = new Date(record.end) const endDate = new Date(record.end)
if (now >= startDate && now < endDate) { if (now >= startDate && now < endDate) {
return "active" return "active"
} else if (now >= endDate) { } else if (now >= endDate) {
return "past" return "past"
} else { } else {
return "future" return "inactive"
} }
} }
} }
return ( return (
<> <>
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3"> <div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
<div> <div>
<h3 className="mb-1 text-lg font-medium"> <h3 className="mb-1 text-lg font-medium">
<Trans>Quiet hours</Trans> <Trans>Quiet hours</Trans>
</h3> </h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <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> <Trans>
</p> Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
</div> </Trans>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> </p>
<DialogTrigger asChild> </div>
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<PlusIcon className="size-4" /> <DialogTrigger asChild>
<span className="ms-1"> <Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
<Trans>Add Quiet Hours</Trans> <PlusIcon className="size-4" />
</span> <span className="ms-1">
</Button> <Trans>Add Quiet Hours</Trans>
</DialogTrigger> </span>
<QuietHoursDialog </Button>
editingRecord={editingRecord} </DialogTrigger>
systems={systems} <QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
onClose={closeDialog} </Dialog>
toast={toast} </div>
/> {data.length > 0 && (
</Dialog> <div className="rounded-md border overflow-x-auto whitespace-nowrap">
</div> <Table>
{data.length > 0 && ( <TableHeader>
<div className="rounded-md border overflow-x-auto whitespace-nowrap"> <TableRow className="border-border/50">
<Table> <TableHead className="px-4">
<TableHeader> <span className="flex items-center gap-2">
<TableRow className="border-border/50"> <ServerIcon className="size-4" />
<TableHead className="px-4"> <Trans>System</Trans>
<span className="flex items-center gap-2"> </span>
<ServerIcon className="size-4" /> </TableHead>
<Trans>System</Trans> <TableHead className="px-4">
</span> <span className="flex items-center gap-2">
</TableHead> <ClockIcon className="size-4" />
<TableHead className="px-4"> <Trans>Type</Trans>
<span className="flex items-center gap-2"> </span>
<ClockIcon className="size-4" /> </TableHead>
<Trans>Type</Trans> <TableHead className="px-4">
</span> <span className="flex items-center gap-2">
</TableHead> <CalendarIcon className="size-4" />
<TableHead className="px-4"> <Trans>Schedule</Trans>
<span className="flex items-center gap-2"> </span>
<ActivityIcon className="size-4" /> </TableHead>
<Trans>State</Trans> <TableHead className="px-4">
</span> <span className="flex items-center gap-2">
</TableHead> <ActivityIcon className="size-4" />
<TableHead className="px-4"> <Trans>State</Trans>
<span className="flex items-center gap-2"> </span>
<CalendarIcon className="size-4" /> </TableHead>
<Trans>Schedule</Trans> <TableHead className="px-4 text-right sr-only">
</span> <Trans>Actions</Trans>
</TableHead> </TableHead>
<TableHead className="px-4 text-right sr-only"> </TableRow>
<Trans>Actions</Trans> </TableHeader>
</TableHead> <TableBody>
</TableRow> {data.map((record) => (
</TableHeader> <TableRow key={record.id}>
<TableBody> <TableCell className="px-4 py-3">
{data.map((record) => ( {record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
<TableRow key={record.id}> </TableCell>
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">
{record.system ? (record.expand?.system?.name || record.system) : <Trans>All Systems</Trans>} {record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
</TableCell> </TableCell>
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>} <TableCell className="px-4 py-3">
</TableCell> {(() => {
<TableCell className="px-4 py-3"> const state = getWindowState(record)
{(() => { const stateConfig = {
const state = getWindowState(record) active: { label: <Trans>Active</Trans>, variant: "success" as const },
const stateConfig = { past: { label: <Trans>Past</Trans>, variant: "danger" as const },
active: { label: <Trans>Active</Trans>, variant: "success" as const }, inactive: { label: <Trans>Inactive</Trans>, variant: "default" 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>
const config = stateConfig[state] })()}
return ( </TableCell>
<Badge variant={config.variant}> <TableCell className="px-4 py-3 text-right">
{config.label} <DropdownMenu>
</Badge> <DropdownMenuTrigger asChild>
) <Button variant="ghost" size="icon" className="size-8">
})()} <span className="sr-only">
</TableCell> <Trans>Open menu</Trans>
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell> </span>
<TableCell className="px-4 py-3 text-right"> <MoreHorizontalIcon className="size-4" />
<DropdownMenu> </Button>
<DropdownMenuTrigger asChild> </DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="size-8"> <DropdownMenuContent align="end">
<span className="sr-only"><Trans>Open menu</Trans></span> <DropdownMenuItem onClick={() => openEditDialog(record)}>
<MoreHorizontalIcon className="size-4" /> <PenSquareIcon className="me-2.5 size-4" />
</Button> <Trans>Edit</Trans>
</DropdownMenuTrigger> </DropdownMenuItem>
<DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => handleDelete(record.id)}>
<DropdownMenuItem onClick={() => openEditDialog(record)}> <Trash2Icon className="me-2.5 size-4" />
<PenSquareIcon className="me-2.5 size-4" /> <Trans>Delete</Trans>
<Trans>Edit</Trans> </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
<DropdownMenuItem onClick={() => handleDelete(record.id)}> </DropdownMenu>
<Trash2Icon className="me-2.5 size-4" /> </TableCell>
<Trans>Delete</Trans> </TableRow>
</DropdownMenuItem> ))}
</DropdownMenuContent> </TableBody>
</DropdownMenu> </Table>
</TableCell> </div>
</TableRow> )}
))} </>
</TableBody> )
</Table>
</div>
)}
</>
)
} }
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time // Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
function formatDateTimeLocal(date: Date): string { function formatDateTimeLocal(date: Date): string {
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, "0")
const hours = String(date.getHours()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, "0")
return `${year}-${month}-${day}T${hours}:${minutes}` return `${year}-${month}-${day}T${hours}:${minutes}`
} }
function QuietHoursDialog({ function QuietHoursDialog({
editingRecord, editingRecord,
systems, systems,
onClose, onClose,
toast, toast,
}: { }: {
editingRecord: QuietHoursRecord | null editingRecord: QuietHoursRecord | null
systems: SystemRecord[] systems: SystemRecord[]
onClose: () => void onClose: () => void
toast: any toast: ReturnType<typeof useToast>["toast"]
}) { }) {
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "") const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system) const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time") const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
const [startDateTime, setStartDateTime] = useState("") const [startDateTime, setStartDateTime] = useState("")
const [endDateTime, setEndDateTime] = useState("") const [endDateTime, setEndDateTime] = useState("")
const [startTime, setStartTime] = useState("") const [startTime, setStartTime] = useState("")
const [endTime, setEndTime] = useState("") const [endTime, setEndTime] = useState("")
useEffect(() => { useEffect(() => {
if (editingRecord) { if (editingRecord) {
setSelectedSystem(editingRecord.system || "") setSelectedSystem(editingRecord.system || "")
setIsGlobal(!editingRecord.system) setIsGlobal(!editingRecord.system)
setWindowType(editingRecord.type) setWindowType(editingRecord.type)
if (editingRecord.type === "daily") { if (editingRecord.type === "daily") {
// Extract time from datetime // Extract time from datetime
const start = new Date(editingRecord.start) const start = new Date(editingRecord.start)
const end = editingRecord.end ? new Date(editingRecord.end) : null const end = editingRecord.end ? new Date(editingRecord.end) : null
setStartTime(start.toTimeString().slice(0, 5)) setStartTime(start.toTimeString().slice(0, 5))
setEndTime(end ? end.toTimeString().slice(0, 5) : "") setEndTime(end ? end.toTimeString().slice(0, 5) : "")
} else { } else {
// For one-time, format as datetime-local (local time, not UTC) // For one-time, format as datetime-local (local time, not UTC)
const startDate = new Date(editingRecord.start) const startDate = new Date(editingRecord.start)
const endDate = editingRecord.end ? new Date(editingRecord.end) : null const endDate = editingRecord.end ? new Date(editingRecord.end) : null
setStartDateTime(formatDateTimeLocal(startDate)) setStartDateTime(formatDateTimeLocal(startDate))
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "") setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
} }
} else { } else {
// Reset form with default dates: today at 12pm and 1pm // Reset form with default dates: today at 12pm and 1pm
const today = new Date() const today = new Date()
const noon = new Date(today) const noon = new Date(today)
noon.setHours(12, 0, 0, 0) noon.setHours(12, 0, 0, 0)
const onePm = new Date(today) const onePm = new Date(today)
onePm.setHours(13, 0, 0, 0) onePm.setHours(13, 0, 0, 0)
setSelectedSystem("") setSelectedSystem("")
setIsGlobal(true) setIsGlobal(true)
setWindowType("one-time") setWindowType("one-time")
setStartDateTime(formatDateTimeLocal(noon)) setStartDateTime(formatDateTimeLocal(noon))
setEndDateTime(formatDateTimeLocal(onePm)) setEndDateTime(formatDateTimeLocal(onePm))
setStartTime("12:00") setStartTime("12:00")
setEndTime("13:00") setEndTime("13:00")
} }
}, [editingRecord]) }, [editingRecord])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
try { try {
let startValue: string let startValue: string
let endValue: string | undefined let endValue: string | undefined
if (windowType === "daily") { if (windowType === "daily") {
// For daily windows, convert local time to UTC // For daily windows, convert local time to UTC
// Create a date with the time in local timezone, then convert to UTC // Create a date with the time in local timezone, then convert to UTC
const startDate = new Date(`2000-01-01T${startTime}:00`) const startDate = new Date(`2000-01-01T${startTime}:00`)
startValue = startDate.toISOString() startValue = startDate.toISOString()
if (endTime) { if (endTime) {
const endDate = new Date(`2000-01-01T${endTime}:00`) const endDate = new Date(`2000-01-01T${endTime}:00`)
endValue = endDate.toISOString() endValue = endDate.toISOString()
} }
} else { } else {
// For one-time windows, use the datetime values // For one-time windows, use the datetime values
startValue = new Date(startDateTime).toISOString() startValue = new Date(startDateTime).toISOString()
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
} }
const data = { const data = {
user: pb.authStore.record?.id, user: pb.authStore.record?.id,
system: isGlobal ? undefined : selectedSystem, system: isGlobal ? undefined : selectedSystem,
type: windowType, type: windowType,
start: startValue, start: startValue,
end: endValue, end: endValue,
} }
if (editingRecord) { if (editingRecord) {
await pb.collection("quiet_hours").update(editingRecord.id, data) await pb.collection("quiet_hours").update(editingRecord.id, data)
toast({ toast({
title: t`Updated`, title: t`Updated`,
description: t`Quiet hours have been updated.`, description: t`Quiet hours have been updated.`,
}) })
} else { } else {
await pb.collection("quiet_hours").create(data) await pb.collection("quiet_hours").create(data)
toast({ toast({
title: t`Created`, title: t`Created`,
description: t`Quiet hours have been created.`, description: t`Quiet hours have been created.`,
}) })
} }
onClose() onClose()
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t`Error`, title: t`Error`,
description: t`Failed to save quiet hours.`, description: t`Failed to save quiet hours.`,
}) })
} }
} }
return ( return (
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}</DialogTitle>
{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>} <DialogDescription>
</DialogTitle> <Trans>Configure quiet hours where notifications will not be sent.</Trans>
<DialogDescription> </DialogDescription>
<Trans>Configure quiet hours where notifications will not be sent.</Trans> </DialogHeader>
</DialogDescription> <form onSubmit={handleSubmit} className="space-y-4">
</DialogHeader> <Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
<form onSubmit={handleSubmit} className="space-y-4"> <TabsList className="grid w-full grid-cols-2">
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}> <TabsTrigger value="global">
<TabsList className="grid w-full grid-cols-2"> <Trans>All Systems</Trans>
<TabsTrigger value="global"> </TabsTrigger>
<Trans>All Systems</Trans> <TabsTrigger value="system">
</TabsTrigger> <Trans>Specific System</Trans>
<TabsTrigger value="system"> </TabsTrigger>
<Trans>Specific System</Trans> </TabsList>
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-4 space-y-4"> <TabsContent value="system" className="mt-4 space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="system"> <Label htmlFor="system">
<Trans>System</Trans> <Trans>System</Trans>
</Label> </Label>
<Select value={selectedSystem} onValueChange={setSelectedSystem}> <Select value={selectedSystem} onValueChange={setSelectedSystem}>
<SelectTrigger id="system"> <SelectTrigger id="system">
<SelectValue placeholder={t`Select a system`} /> <SelectValue placeholder={t`Select a system`} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{systems.map((system) => ( {systems.map((system) => (
<SelectItem key={system.id} value={system.id}> <SelectItem key={system.id} value={system.id}>
{system.name} {system.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* Hidden input for native form validation */} {/* Hidden input for native form validation */}
<input <input
className="sr-only" className="sr-only"
type="text" type="text"
tabIndex={-1} tabIndex={-1}
autoComplete="off" autoComplete="off"
value={selectedSystem} value={selectedSystem}
onChange={() => { }} onChange={() => {}}
required={!isGlobal} required={!isGlobal}
/> />
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </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 Date & 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"> <div className="grid gap-2">
<Label htmlFor="end-datetime"> <Label htmlFor="type">
<Trans>End Date & Time</Trans> <Trans>Type</Trans>
</Label> </Label>
<Input <Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
id="end-datetime" <SelectTrigger id="type">
type="datetime-local" <SelectValue />
value={endDateTime} </SelectTrigger>
onChange={(e) => setEndDateTime(e.target.value)} <SelectContent>
min={startDateTime || formatDateTimeLocal(new Date())} <SelectItem value="one-time">
required <Trans>One-time</Trans>
className="tabular-nums tracking-tighter" </SelectItem>
/> <SelectItem value="daily">
<Trans>Daily</Trans>
</SelectItem>
</SelectContent>
</Select>
</div> </div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="start-time">
<Trans>Start Time</Trans>
</Label>
<Input
id="start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="end-time">
<Trans>End Time</Trans>
</Label>
<Input id="end-time" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} required />
</div>
</>
)}
<DialogFooter> {windowType === "one-time" ? (
<Button type="button" variant="outline" onClick={onClose}> <>
<Trans>Cancel</Trans> <div className="grid gap-2">
</Button> <Label htmlFor="start-datetime">
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button> <Trans>Start Time</Trans>
</DialogFooter> </Label>
</form> <Input
</DialogContent> 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>
)
} }

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
@@ -218,7 +219,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}, },
}, },
{ {
accessorFn: ({ info }) => (info.bb || (info.b || 0) * 1024 * 1024) || undefined, accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
id: "net", id: "net",
name: () => t`Net`, name: () => t`Net`,
size: 0, size: 0,
@@ -291,7 +292,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0, [STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
})} })}
/> />
{totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span> {totalCount}{" "}
<span className="text-muted-foreground text-sm -ms-0.5">
({t`Failed`.toLowerCase()}: {numFailed})
</span>
</span> </span>
) )
}, },
@@ -377,9 +381,9 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const meterClass = cn( const meterClass = cn(
"h-full", "h-full",
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) || (info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) || (threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down STATUS_COLORS.down
) )
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full"> <div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
@@ -395,7 +399,6 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status, id } = info.row.original const { info: sysInfo, status, id } = info.row.original
const extraFs = Object.entries(sysInfo.efs ?? {}) const extraFs = Object.entries(sysInfo.efs ?? {})
// No extra disks - show basic meter
if (extraFs.length === 0) { if (extraFs.length === 0) {
return TableCellWithMeter(info) return TableCellWithMeter(info)
} }
@@ -405,10 +408,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
// sort extra disks by percentage descending // sort extra disks by percentage descending
extraFs.sort((a, b) => b[1] - a[1]) extraFs.sort((a, b) => b[1] - a[1])
function getMeterClass(pct: number) { function getIndicatorColor(pct: number) {
const threshold = getMeterState(pct) const threshold = getMeterState(pct)
return cn( return (
"h-full",
(status !== SystemStatus.Up && STATUS_COLORS.paused) || (status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) || (threshold === MeterState.Warn && STATUS_COLORS.pending) ||
@@ -416,28 +418,43 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
) )
} }
function getMeterClass(pct: number) {
return cn("h-full", getIndicatorColor(pct))
}
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10"> <Link
href={getPagePath($router, "system", { id })}
tabIndex={-1}
className="flex flex-col gap-0.5 w-full relative z-10"
>
<div className="flex gap-2 items-center tabular-nums tracking-tight"> <div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span> <span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden"> <span className="flex-1 min-w-8 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative">
{/* Root disk */} {/* Root disk */}
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span> <span
{/* Extra disks */} className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
{extraFs.map(([_name, pct], index) => ( style={{ width: `${rootDiskPct}%` }}
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span> ></span>
{/* Extra disk indicators */}
{extraFs.map(([name, pct]) => (
<span
key={name}
className={cn("size-1.5 rounded-full shrink-0 outline-[0.5px] outline-muted", getIndicatorColor(pct))}
/>
))} ))}
</span> </span>
</div> </div>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-xs pb-2"> <TooltipContent side="right" className="max-w-xs pb-2">
<div className="grid gap-1.5"> <div className="grid gap-1">
<div className="grid gap-0.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="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"> <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="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="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
@@ -448,7 +465,9 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
{extraFs.map(([name, pct]) => { {extraFs.map(([name, pct]) => {
return ( return (
<div key={name} className="grid gap-0.5"> <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="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"> <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="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="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
@@ -469,7 +488,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas
return ( return (
<span <span
className={cn("shrink-0 size-2 rounded-full", className)} className={cn("shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }} // style={{ marginBottom: "-1px" }}
/> />
) )
} }