tweaks to custom meter percentages

This commit is contained in:
henrygd
2025-08-02 20:53:49 -04:00
parent df9e2dec28
commit 5f7950b474
7 changed files with 65 additions and 126 deletions

View File

@@ -14,18 +14,6 @@ type UserManager struct {
app core.App
}
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
// UnitDisk uint8 `json:"unitDisk"` // 0 for bytes, 1 for bits
// New field for alert history retention (e.g., "1m", "3m", "6m", "1y")
AlertHistoryRetention string `json:"alertHistoryRetention,omitempty"`
}
func NewUserManager(app core.App) *UserManager {
return &UserManager{
app: app,
@@ -44,7 +32,10 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults (zero values can be ignored)
settings := UserSettings{
settings := struct {
ChartTime string `json:"chartTime"`
Emails []string `json:"emails"`
}{
ChartTime: "1h",
}
record.UnmarshalJSONField("settings", &settings)
@@ -59,10 +50,7 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
log.Println("failed to get user email", "err", err)
return err
}
settings.NotificationEmails = []string{user.Email}
if len(settings.NotificationWebhooks) == 0 {
settings.NotificationWebhooks = []string{""}
}
settings.Emails = []string{user.Email}
record.Set("settings", settings)
return e.Next()
}

View File

@@ -12,29 +12,17 @@ import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro"
import { Input } from "@/components/ui/input"
// import { setLang } from "@/lib/i18n"
import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
// Remove all per-metric threshold state and UI
// Only keep general yellow/red threshold state and UI
const [yellow, setYellow] = useState(userSettings.meterThresholds?.yellow ?? 65)
const [red, setRed] = useState(userSettings.meterThresholds?.red ?? 90)
function handleResetThresholds() {
setYellow(65)
setRed(90)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
data.meterThresholds = { yellow, red }
await saveSettings(data)
setIsLoading(false)
}
@@ -114,45 +102,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p>
</div>
<Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Dashboard meter thresholds</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Choose when the dashboard meters changes colors, based on percentage values.</Trans>
</p>
</div>
<div className="flex gap-4 items-end">
<div>
<Label htmlFor="yellow-threshold"><Trans>Warning threshold (%)</Trans></Label>
<Input
type="number"
id="yellow-threshold"
min={1}
max={100}
value={yellow}
onChange={e => setYellow(Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="red-threshold"><Trans>Danger threshold (%)</Trans></Label>
<Input
type="number"
id="red-threshold"
min={1}
max={100}
value={red}
onChange={e => setRed(Number(e.target.value))}
/>
</div>
<Button type="button" variant="outline" onClick={handleResetThresholds} disabled={isLoading} className="mt-4">
<Trans>Reset to default</Trans>
</Button>
</div>
</div>
<Separator />
{/* Unit preferences section fixed and wrapped in a div */}
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
@@ -232,6 +181,47 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans>Warning thresholds</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Set percentage thresholds for meter colors.</Trans>
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
<div className="space-y-1">
<Label htmlFor="colorWarn">
<Trans>Warning (%)</Trans>
</Label>
<Input
id="colorWarn"
name="colorWarn"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorWarn ?? 65}
/>
</div>
<div className="space-y-1">
<Label htmlFor="colorCrit">
<Trans>Critical (%)</Trans>
</Label>
<Input
id="colorCrit"
name="colorCrit"
type="number"
min={1}
max={100}
className="min-w-24"
defaultValue={userSettings.colorCrit ?? 90}
/>
</div>
</div>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>

View File

@@ -10,7 +10,7 @@ import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js"
import { UserSettings } from "@/types"
import General from "./general.tsx"
import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx"

View File

@@ -279,8 +279,11 @@ function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
</Button>
)
}
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const userSettings = useStore($userSettings)
const val = Number(info.getValue()) || 0
const { colorWarn = 65, colorCrit = 90 } = userSettings
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
@@ -289,8 +292,8 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
className={cn(
"absolute inset-0 w-full h-full origin-left",
(info.row.original.status !== "up" && "bg-primary/30") ||
(val < 65 && "bg-green-500") ||
(val < 90 && "bg-yellow-500") ||
(val < colorWarn && "bg-green-500") ||
(val < colorCrit && "bg-yellow-500") ||
"bg-red-600"
)}
style={{

View File

@@ -51,49 +51,6 @@ import AlertButton from "../alerts/alert-button"
type ViewMode = "table" | "grid"
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = (info.getValue() as number) || 0
const userSettings = useStore($userSettings)
const yellow = userSettings?.meterThresholds?.yellow ?? 65
const red = userSettings?.meterThresholds?.red ?? 90
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{decimalString(val, 1)}%</span>
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span
className={cn(
"absolute inset-0 w-full h-full origin-left",
(info.row.original.status !== "up" && "bg-primary/30") ||
(val < yellow! && "bg-green-500") ||
(val < red! && "bg-yellow-500") ||
"bg-red-600"
)}
style={{
transform: `scalex(${val / 100})`,
}}
></span>
</span>
</div>
)
}
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context
// @ts-ignore
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
return (
<Button
variant="ghost"
className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="me-2 size-4" />}
{name()}
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button>
)
}
export default function SystemsTable() {
const data = useStore($systems)
const { i18n, t } = useLingui()

View File

@@ -24,17 +24,21 @@ export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
/** Whether to display average or max chart values */
export const $maxValues = atom(false)
// export const UserSettingsSchema = v.object({
// chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
// emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
// webhooks: v.optional(v.array(v.string())),
// colorWarn: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// colorDanger: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// unitTemp: v.optional(v.enum(Unit)),
// unitNet: v.optional(v.enum(Unit)),
// unitDisk: v.optional(v.enum(Unit)),
// })
/** User settings */
export const $userSettings = map<UserSettings>({
chartTime: "1h",
emails: [pb.authStore.record?.email || ""],
meterThresholds: {
yellow: 65,
red: 90,
},
// unitTemp: "celsius",
// unitNet: "mbps",
// unitDisk: "mbps",
})
// update local storage on change
$userSettings.subscribe((value) => {

View File

@@ -224,17 +224,14 @@ export interface ChartTimeData {
}
export interface UserSettings {
// lang?: string
chartTime: ChartTimes
emails?: string[]
webhooks?: string[]
meterThresholds?: {
yellow?: number
red?: number
}
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
colorWarn?: number
colorCrit?: number
}
type ChartDataContainer = {