Compare commits

..

4 Commits

Author SHA1 Message Date
henrygd
1275af956b updates 2025-11-24 16:57:06 -05:00
henrygd
bf36015bd9 updates 2025-11-24 16:40:18 -05:00
henrygd
56807dc5e4 updates 2025-11-21 17:49:17 -05:00
henrygd
56a9915b43 quiet hours progress 2025-11-21 17:09:42 -05:00
4 changed files with 490 additions and 511 deletions

View File

@@ -48,8 +48,7 @@
}, },
"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" crossorigin="use-credentials" /> <link rel="manifest" href="./static/manifest.json" />
<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,30 +1,26 @@
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 { import { MoreHorizontalIcon, PlusIcon, Trash2Icon, ServerIcon, ClockIcon, CalendarIcon, ActivityIcon, PenSquareIcon } from "lucide-react"
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import {
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"
@@ -37,494 +33,497 @@ 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) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r))) 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)) }
} 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: "numeric", minute: "2-digit" }) const startTime = new Date(record.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) const endTime = new Date(record.end).toLocaleTimeString([], { hour: "2-digit", 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" | "inactive" => { const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => {
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" : "inactive" return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future"
} else { } else {
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive" return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future"
} }
} 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 "inactive" return "future"
} }
} }
} }
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> <Trans>Schedule quiet hours where notifications will not be sent, such as during maintenance periods.</Trans>
Schedule quiet hours where notifications will not be sent, such as during maintenance periods. </p>
</Trans> </div>
</p> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
</div> <DialogTrigger asChild>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
<DialogTrigger asChild> <PlusIcon className="size-4" />
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}> <span className="ms-1">
<PlusIcon className="size-4" /> <Trans>Add Quiet Hours</Trans>
<span className="ms-1"> </span>
<Trans>Add Quiet Hours</Trans> </Button>
</span> </DialogTrigger>
</Button> <QuietHoursDialog
</DialogTrigger> editingRecord={editingRecord}
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} /> systems={systems}
</Dialog> onClose={closeDialog}
</div> toast={toast}
{data.length > 0 && ( />
<div className="rounded-md border overflow-x-auto whitespace-nowrap"> </Dialog>
<Table> </div>
<TableHeader> {data.length > 0 && (
<TableRow className="border-border/50"> <div className="rounded-md border overflow-x-auto whitespace-nowrap">
<TableHead className="px-4"> <Table>
<span className="flex items-center gap-2"> <TableHeader>
<ServerIcon className="size-4" /> <TableRow className="border-border/50">
<Trans>System</Trans> <TableHead className="px-4">
</span> <span className="flex items-center gap-2">
</TableHead> <ServerIcon className="size-4" />
<TableHead className="px-4"> <Trans>System</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> <ClockIcon className="size-4" />
<TableHead className="px-4"> <Trans>Type</Trans>
<span className="flex items-center gap-2"> </span>
<CalendarIcon className="size-4" /> </TableHead>
<Trans>Schedule</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>
<ActivityIcon className="size-4" /> </TableHead>
<Trans>State</Trans> <TableHead className="px-4">
</span> <span className="flex items-center gap-2">
</TableHead> <CalendarIcon className="size-4" />
<TableHead className="px-4 text-right sr-only"> <Trans>Schedule</Trans>
<Trans>Actions</Trans> </span>
</TableHead> </TableHead>
</TableRow> <TableHead className="px-4 text-right sr-only">
</TableHeader> <Trans>Actions</Trans>
<TableBody> </TableHead>
{data.map((record) => ( </TableRow>
<TableRow key={record.id}> </TableHeader>
<TableCell className="px-4 py-3"> <TableBody>
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>} {data.map((record) => (
</TableCell> <TableRow key={record.id}>
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>} {record.system ? (record.expand?.system?.name || record.system) : <Trans>All Systems</Trans>}
</TableCell> </TableCell>
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell> <TableCell className="px-4 py-3">
<TableCell className="px-4 py-3"> {record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
{(() => { </TableCell>
const state = getWindowState(record) <TableCell className="px-4 py-3">
const stateConfig = { {(() => {
active: { label: <Trans>Active</Trans>, variant: "success" as const }, const state = getWindowState(record)
past: { label: <Trans>Past</Trans>, variant: "danger" as const }, const stateConfig = {
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const }, active: { label: <Trans>Active</Trans>, variant: "success" as const },
} past: { label: <Trans>Past</Trans>, variant: "danger" as const },
const config = stateConfig[state] future: { label: <Trans>Future</Trans>, variant: "default" as const },
return <Badge variant={config.variant}>{config.label}</Badge> }
})()} const config = stateConfig[state]
</TableCell> return (
<TableCell className="px-4 py-3 text-right"> <Badge variant={config.variant}>
<DropdownMenu> {config.label}
<DropdownMenuTrigger asChild> </Badge>
<Button variant="ghost" size="icon" className="size-8"> )
<span className="sr-only"> })()}
<Trans>Open menu</Trans> </TableCell>
</span> <TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
<MoreHorizontalIcon className="size-4" /> <TableCell className="px-4 py-3 text-right">
</Button> <DropdownMenu>
</DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<DropdownMenuContent align="end"> <Button variant="ghost" size="icon" className="size-8">
<DropdownMenuItem onClick={() => openEditDialog(record)}> <span className="sr-only"><Trans>Open menu</Trans></span>
<PenSquareIcon className="me-2.5 size-4" /> <MoreHorizontalIcon className="size-4" />
<Trans>Edit</Trans> </Button>
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => handleDelete(record.id)}> <DropdownMenuContent align="end">
<Trash2Icon className="me-2.5 size-4" /> <DropdownMenuItem onClick={() => openEditDialog(record)}>
<Trans>Delete</Trans> <PenSquareIcon className="me-2.5 size-4" />
</DropdownMenuItem> <Trans>Edit</Trans>
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> <DropdownMenuItem onClick={() => handleDelete(record.id)}>
</TableCell> <Trash2Icon className="me-2.5 size-4" />
</TableRow> <Trans>Delete</Trans>
))} </DropdownMenuItem>
</TableBody> </DropdownMenuContent>
</Table> </DropdownMenu>
</div> </TableCell>
)} </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: ReturnType<typeof useToast>["toast"] toast: any
}) { }) {
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>{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}</DialogTitle> <DialogTitle>
<DialogDescription> {editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}
<Trans>Configure quiet hours where notifications will not be sent.</Trans> </DialogTitle>
</DialogDescription> <DialogDescription>
</DialogHeader> <Trans>Configure quiet hours where notifications will not be sent.</Trans>
<form onSubmit={handleSubmit} className="space-y-4"> </DialogDescription>
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}> </DialogHeader>
<TabsList className="grid w-full grid-cols-2"> <form onSubmit={handleSubmit} className="space-y-4">
<TabsTrigger value="global"> <Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
<Trans>All Systems</Trans> <TabsList className="grid w-full grid-cols-2">
</TabsTrigger> <TabsTrigger value="global">
<TabsTrigger value="system"> <Trans>All Systems</Trans>
<Trans>Specific System</Trans> </TabsTrigger>
</TabsTrigger> <TabsTrigger value="system">
</TabsList> <Trans>Specific System</Trans>
</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="type"> <Label htmlFor="end-datetime">
<Trans>Type</Trans> <Trans>End Date & Time</Trans>
</Label> </Label>
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}> <Input
<SelectTrigger id="type"> id="end-datetime"
<SelectValue /> type="datetime-local"
</SelectTrigger> value={endDateTime}
<SelectContent> onChange={(e) => setEndDateTime(e.target.value)}
<SelectItem value="one-time"> min={startDateTime || formatDateTimeLocal(new Date())}
<Trans>One-time</Trans> required
</SelectItem> className="tabular-nums tracking-tighter"
<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>
</>
)}
{windowType === "one-time" ? ( <DialogFooter>
<> <Button type="button" variant="outline" onClick={onClose}>
<div className="grid gap-2"> <Trans>Cancel</Trans>
<Label htmlFor="start-datetime"> </Button>
<Trans>Start Time</Trans> <Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
</Label> </DialogFooter>
<Input </form>
id="start-datetime" </DialogContent>
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,4 +1,3 @@
/** 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"
@@ -219,7 +218,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,
@@ -292,10 +291,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0, [STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
})} })}
/> />
{totalCount}{" "} {totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span>
<span className="text-muted-foreground text-sm -ms-0.5">
({t`Failed`.toLowerCase()}: {numFailed})
</span>
</span> </span>
) )
}, },
@@ -381,9 +377,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">
@@ -399,6 +395,7 @@ 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)
} }
@@ -408,9 +405,10 @@ 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 getIndicatorColor(pct: number) { function getMeterClass(pct: number) {
const threshold = getMeterState(pct) const threshold = getMeterState(pct)
return ( return cn(
"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) ||
@@ -418,43 +416,28 @@ 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 <Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
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 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative"> <span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
{/* Root disk */} {/* Root disk */}
<span <span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
className={cn("absolute inset-0", getMeterClass(rootDiskPct))} {/* Extra disks */}
style={{ width: `${rootDiskPct}%` }} {extraFs.map(([_name, pct], index) => (
></span> <span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></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"> <div className="grid gap-1.5">
<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"> <div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
<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">
@@ -465,9 +448,7 @@ 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"> <div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
{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">
@@ -488,7 +469,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" }}
/> />
) )
} }