diff --git a/internal/site/src/components/routes/settings/general.tsx b/internal/site/src/components/routes/settings/general.tsx index dfd4bc54..692f8463 100644 --- a/internal/site/src/components/routes/settings/general.tsx +++ b/internal/site/src/components/routes/settings/general.tsx @@ -63,7 +63,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us - dynamicActivate(lang)}> diff --git a/internal/site/src/components/routes/settings/layout.tsx b/internal/site/src/components/routes/settings/layout.tsx index 0c721036..11bb89a6 100644 --- a/internal/site/src/components/routes/settings/layout.tsx +++ b/internal/site/src/components/routes/settings/layout.tsx @@ -14,7 +14,7 @@ import { lazy, useEffect } from "react" import { $router } from "@/components/router.tsx" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx" import { toast } from "@/components/ui/use-toast.ts" -import { pb } from "@/lib/api" +import { saveUserSettings } from "@/lib/api" import { $userSettings } from "@/lib/stores.ts" import type { UserSettings } from "@/types" import { Separator } from "../../ui/separator" @@ -36,24 +36,13 @@ const HeartbeatSettings = lazy(heartbeatSettingsImport) export async function saveSettings(newSettings: Partial) { try { - // get fresh copy of settings - const req = await pb.collection("user_settings").getFirstListItem("", { - fields: "id,settings", - }) - // update user settings - const updatedSettings = await pb.collection("user_settings").update(req.id, { - settings: { - ...req.settings, - ...newSettings, - }, - }) - $userSettings.set(updatedSettings.settings) + await saveUserSettings(newSettings) toast({ title: t`Settings saved`, description: t`Your user settings have been updated.`, }) } catch (e) { - // console.error('update settings', e) + console.error("save settings", e) toast({ title: t`Failed to save settings`, description: t`Check logs for more details.`, diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index ef143637..0a5dc15f 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -1,9 +1,9 @@ import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { subscribeKeys } from "nanostores" -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useContainerChartConfigs } from "@/components/charts/hooks" -import { pb } from "@/lib/api" +import { pb, queueUserSettings } from "@/lib/api" import { SystemStatus } from "@/lib/enums" import { $allSystemsById, @@ -15,7 +15,7 @@ import { $systems, $userSettings, } from "@/lib/stores" -import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils" +import { chartTimeData, listen, parseSemVer } from "@/lib/utils" import type { ChartData, ContainerStatsRecord, @@ -35,8 +35,42 @@ export function useSystemData(id: string) { const systems = useStore($systems) const chartTime = useStore($chartTime) const maxValues = useStore($maxValues) - const [grid, setGrid] = useBrowserStorage("grid", true) - const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default") + const [grid, _setGrid] = useState( + () => $userSettings.get().grid ?? JSON.parse(localStorage.getItem("besz-grid") ?? "null") ?? true + ) + const [displayMode, _setDisplayMode] = useState<"default" | "tabs">( + () => + $userSettings.get().displayMode ?? + (JSON.parse(localStorage.getItem("besz-displayMode") || "null") as "default" | "tabs" | null) ?? + "default" + ) + + const applied = useRef(new Set()) + useEffect(() => { + return subscribeKeys($userSettings, ["grid", "displayMode"], (vals) => { + if (!applied.current.has("grid") && vals.grid !== undefined) { + applied.current.add("grid") + _setGrid(vals.grid) + } + if (!applied.current.has("displayMode") && vals.displayMode !== undefined) { + applied.current.add("displayMode") + _setDisplayMode(vals.displayMode) + } + }) + }, []) + + const setGrid = useCallback((v: boolean) => { + _setGrid(v) + localStorage.setItem("besz-grid", JSON.stringify(v)) + $userSettings.setKey("grid", v) + queueUserSettings({ grid: v }) + }, []) + const setDisplayMode = useCallback((v: "default" | "tabs") => { + _setDisplayMode(v) + localStorage.setItem("besz-displayMode", JSON.stringify(v)) + $userSettings.setKey("displayMode", v) + queueUserSettings({ displayMode: v }) + }, []) const [activeTab, setActiveTabRaw] = useState("core") const [mountedTabs, setMountedTabs] = useState(() => new Set(["core"])) const tabsRef = useRef(["core", "disk"]) diff --git a/internal/site/src/components/systems-table/systems-table.tsx b/internal/site/src/components/systems-table/systems-table.tsx index c3b30afd..2bc08240 100644 --- a/internal/site/src/components/systems-table/systems-table.tsx +++ b/internal/site/src/components/systems-table/systems-table.tsx @@ -1,5 +1,6 @@ import { Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" +import { subscribeKeys } from "nanostores" import { getPagePath } from "@nanostores/router" import { type ColumnDef, @@ -26,7 +27,7 @@ import { Settings2Icon, XIcon, } from "lucide-react" -import { memo, useEffect, useMemo, useRef, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -42,8 +43,9 @@ import { import { Input } from "@/components/ui/input" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { SystemStatus } from "@/lib/enums" -import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores" -import { cn, runOnce, useBrowserStorage } from "@/lib/utils" +import { queueUserSettings } from "@/lib/api" +import { $downSystems, $pausedSystems, $systems, $upSystems, $userSettings } from "@/lib/stores" +import { cn, runOnce } from "@/lib/utils" import type { SystemRecord } from "@/types" import AlertButton from "../alerts/alert-button" import { $router, Link } from "../router" @@ -62,14 +64,83 @@ export default function SystemsTable() { const pausedSystems = $pausedSystems.get() const { i18n, t } = useLingui() const [filter, setFilter] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [sorting, setSorting] = useBrowserStorage( - "sortMode", - [{ id: "system", desc: false }], - sessionStorage + const [statusFilter, setStatusFilter] = useState( + () => + $userSettings.get().statusFilter ?? + (JSON.parse(localStorage.getItem("besz-statusFilter") || "null") as StatusFilter | null) ?? + "all" + ) + const [sorting, setSorting] = useState( + () => + $userSettings.get().sortMode ?? + JSON.parse(sessionStorage.getItem("besz-sortMode") || "null") ?? [{ id: "system", desc: false }] ) const [columnFilters, setColumnFilters] = useState([]) - const [columnVisibility, setColumnVisibility] = useBrowserStorage("cols", {}) + const [columnVisibility, setColumnVisibility] = useState( + () => $userSettings.get().cols ?? JSON.parse(localStorage.getItem("besz-cols") || "{}") + ) + + // Apply settings from server once they load (handles incognito / new devices) + const applied = useRef(new Set()) + useEffect(() => { + return subscribeKeys($userSettings, ["cols", "statusFilter", "viewMode", "sortMode"], (vals) => { + if (!applied.current.has("cols") && vals.cols !== undefined) { + applied.current.add("cols") + setColumnVisibility(vals.cols) + } + if (!applied.current.has("statusFilter") && vals.statusFilter !== undefined) { + applied.current.add("statusFilter") + setStatusFilter(vals.statusFilter) + } + if (!applied.current.has("viewMode") && vals.viewMode !== undefined) { + applied.current.add("viewMode") + setViewMode(vals.viewMode) + } + if (!applied.current.has("sortMode") && vals.sortMode !== undefined) { + applied.current.add("sortMode") + setSorting(vals.sortMode) + } + }) + }, []) + + const handleColumnVisibilityChange = useCallback( + (updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => { + setColumnVisibility((prev) => { + const next = typeof updater === "function" ? updater(prev) : updater + localStorage.setItem("besz-cols", JSON.stringify(next)) + $userSettings.setKey("cols", next) + queueUserSettings({ cols: next }) + return next + }) + }, + [] + ) + + const handleStatusFilterChange = useCallback((value: string) => { + const next = value as StatusFilter + setStatusFilter(next) + localStorage.setItem("besz-statusFilter", JSON.stringify(next)) + $userSettings.setKey("statusFilter", next) + queueUserSettings({ statusFilter: next }) + }, []) + + const handleViewModeChange = useCallback((view: string) => { + const next = view as ViewMode + setViewMode(next) + localStorage.setItem("besz-viewMode", JSON.stringify(next)) + $userSettings.setKey("viewMode", next) + queueUserSettings({ viewMode: next }) + }, []) + + const handleSortingChange = useCallback((updater: SortingState | ((prev: SortingState) => SortingState)) => { + setSorting((prev) => { + const next = typeof updater === "function" ? updater(prev) : updater + sessionStorage.setItem("besz-sortMode", JSON.stringify(next)) + $userSettings.setKey("sortMode", next) + queueUserSettings({ sortMode: next }) + return next + }) + }, []) const locale = i18n.locale @@ -87,10 +158,12 @@ export default function SystemsTable() { return Object.values(pausedSystems) ?? [] }, [data, statusFilter]) - const [viewMode, setViewMode] = useBrowserStorage( - "viewMode", - // show grid view on mobile if there are less than 200 systems (looks better but table is more efficient) - window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table" + const [viewMode, setViewMode] = useState( + () => + $userSettings.get().viewMode ?? + (JSON.parse(localStorage.getItem("besz-viewMode") || "null") as ViewMode | null) ?? + // show grid view on mobile if there are less than 200 systems (looks better but table is more efficient) + (window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table") ) useEffect(() => { @@ -105,11 +178,11 @@ export default function SystemsTable() { data: filteredData, columns: columnDefs, getCoreRowModel: getCoreRowModel(), - onSortingChange: setSorting, + onSortingChange: handleSortingChange, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), - onColumnVisibilityChange: setColumnVisibility, + onColumnVisibilityChange: handleColumnVisibilityChange, state: { sorting, columnFilters, @@ -181,11 +254,7 @@ export default function SystemsTable() { Layout - setViewMode(view as ViewMode)} - > + e.preventDefault()} className="gap-2"> Table @@ -206,7 +275,7 @@ export default function SystemsTable() { setStatusFilter(value as StatusFilter)} + onValueChange={handleStatusFilterChange} > e.preventDefault()}> All Systems @@ -245,7 +314,9 @@ export default function SystemsTable() { { e.preventDefault() - setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }]) + handleSortingChange([ + { id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }, + ]) }} key={column.id} > diff --git a/internal/site/src/lib/api.ts b/internal/site/src/lib/api.ts index 55245b4e..cfa44d98 100644 --- a/internal/site/src/lib/api.ts +++ b/internal/site/src/lib/api.ts @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro" import PocketBase from "pocketbase" import { basePath } from "@/components/router" import { toast } from "@/components/ui/use-toast" +import { dynamicActivate, getLocale } from "@/lib/i18n" import type { ChartTimes, UserSettings } from "@/types" import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores" import { chartTimeData, debounce } from "./utils" @@ -52,11 +53,45 @@ export function logOut() { pb.realtime.unsubscribe() } +/** Save a partial update to user settings in database immediately */ +export async function saveUserSettings(newSettings: Partial) { + // get fresh copy of settings so concurrent changes aren't overwritten + const req = await pb.collection("user_settings").getFirstListItem("", { fields: "id,settings" }) + const updatedSettings = await pb.collection("user_settings").update(req.id, { + settings: { + ...req.settings, + ...newSettings, + }, + }) + $userSettings.set(updatedSettings.settings) +} + +// keys queued by queueUserSettings, flushed together in a single request so that +// two debounced saves for different keys can't race each other's read-modify-write +// and silently drop one of the changes +let queuedSettings: Partial = {} + +const flushQueuedSettings = debounce(() => { + const toSave = queuedSettings + queuedSettings = {} + if (Object.keys(toSave).length === 0) { + return + } + saveUserSettings(toSave).catch(console.error) +}, 1000) + +/** Queue a partial user settings update, merging with any other pending keys and saving them together after a debounce window */ +export function queueUserSettings(newSettings: Partial) { + queuedSettings = { ...queuedSettings, ...newSettings } + flushQueuedSettings() +} + /** Fetch or create user settings in database */ export async function updateUserSettings() { try { const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" }) $userSettings.set(req.settings) + dynamicActivate(req.settings.lang || getLocale()) return } catch (e) { console.error("get settings", e) @@ -65,6 +100,7 @@ export async function updateUserSettings() { try { const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id }) $userSettings.set(createdSettings.settings) + dynamicActivate(createdSettings.settings.lang || getLocale()) } catch (e) { console.error("create settings", e) } diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index bcb1f8cb..a4483501 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -121,6 +121,7 @@ const Layout = () => { const I18nApp = () => { useEffect(() => { + // Activate a locale so I18nProvider can mount App and load the account settings. dynamicActivate(getLocale()) }, []) diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 5fc70d75..f9a7ef7c 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -370,6 +370,13 @@ export interface UserSettings { colorCrit?: number hourFormat?: HourFormat layoutWidth?: number + lang?: string + cols?: Record + statusFilter?: "all" | "up" | "down" | "paused" | "pending" + viewMode?: "table" | "grid" + sortMode?: Array<{ id: string; desc: boolean }> + grid?: boolean + displayMode?: "default" | "tabs" } type ChartDataContainer = {