refactor(ui): simplify indicator to status dot on board logo

Use Mantine Indicator with processing animation instead of custom CSS.
Switch from HoverCard to Menu with grouped sections. Remove beep,
position dot next to board logo, hide in edit mode.
This commit is contained in:
Thomas Camlong committed 2026-05-28 18:07:03 +02:00
1 parent fc80a61aef
commit cb5ae65f8a
4 files changed
+156 -125

No files matched your search

@@ -0,0 +1,47 @@
---
name: Widget Refresh Indicator
overview: "Add a board-header refresh indicator for widget/server-state queries using TanStack Query as the shared state source, then continue the PR #5768 pattern by removing widget-level blocking spinners where needed."
todos:
- id: switch-branch
content: After approval, switch the workspace to `agent/cebd0dad` and confirm the PR diff is present.
status: completed
- id: cache-hook
content: Create a TanStack Query cache-inspection hook that returns refresh counts and normalized query status rows.
status: completed
- id: header-ui
content: Add the hoverable orange/green header indicator immediately before the board edit button.
status: completed
- id: calendar-cleanup
content: Reconcile the calendar widget PR change with the new global indicator, removing redundant widget-local loading UI if appropriate.
status: completed
- id: verify
content: Run targeted typechecks and manually validate refresh, completion, hover details, and beep behavior.
status: completed
isProject: false
---
# Widget Refresh Indicator Plan
## Findings
- PR [#5768](https://github.com/homarr-labs/homarr/pull/5768) is on `agent/cebd0dad` and currently only changes [packages/widgets/src/calendar/component.tsx](packages/widgets/src/calendar/component.tsx): `useSuspenseQuery` became `useQuery`, with a small widget-local loader.
- The current workspace is on `feat/add-integration-from-dashboard`, so implementation should first move to `agent/cebd0dad` after approval.
- Homarr already uses Jotai for small client UI state in [apps/nextjs/src/components/layout/header/burger.tsx](apps/nextjs/src/components/layout/header/burger.tsx), but there is no Zustand pattern. For this feature, TanStack Query itself should be the shared store.
- The client `QueryClient` lives in [apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx](apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx). TanStack Query v5 supports `useIsFetching()` for a global fetching count and `queryClient.getQueryCache().subscribe()` / `findAll()` for cache inspection.
- The board header actions are in [apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx](apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx); the indicator should be inserted immediately before `<EditModeMenu />`, which places it to the left of the edit button.
## Implementation Approach
- Add a small client hook/component, likely [apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx](apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx), that reads from `useQueryClient()` and subscribes to the query cache.
- Derive a normalized list from cached queries: query path/key, fetching status, `status`, `dataUpdatedAt`, stale state via the query object, error state, and integration IDs found in query inputs/data where present.
- Count active refreshes with `fetchStatus === "fetching"`, using a `useIsFetching()` value as a cheap re-render trigger and cache subscription for details.
- Render a Mantine `HeaderButton`/`ActionIcon` with a tiny `Indicator`: orange while one or more relevant queries are fetching, green briefly after a refresh batch completes, then idle/disabled when nothing is active.
- Use a Mantine `HoverCard` for the details: overall status, number of refreshing queries, last updated/stale state, and a compact list of current integration/widget query entries.
- Add a small Web Audio beep when the indicator transitions from fetching to complete. It should be best-effort and fail silently if the browser blocks audio before user interaction.
- Add translation keys only to [packages/translation/src/lang/en.json](packages/translation/src/lang/en.json); other locales fall back to English through the existing merge in [packages/translation/src/request.ts](packages/translation/src/request.ts).
## Widget Migration Scope
- Keep PR #5768’s calendar behavior, but remove the widget-local loader if the header indicator fully replaces it.
- Do not attempt to convert all `useSuspenseQuery` widgets in the same pass unless the branch goal expands. The first version can show global activity for all query cache entries, while follow-up widget migrations replace blocking `useSuspenseQuery` with `useQuery` where each widget can render meaningful stale/empty content.
## Verification
- Run a targeted typecheck such as `pnpm -F nextjs typecheck` and `pnpm -F widgets typecheck` after implementation.
- Skip lint cleanup unless explicitly requested, matching your preference to ignore linter errors.
@@ -37,7 +37,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { QueryRefreshIndicator } from "~/components/layout/header/query-refresh-indicator";
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
@@ -45,19 +44,13 @@ export const BoardContentHeaderActions = () => {
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return (
<>
<QueryRefreshIndicator />
<SelectBoardsMenu />
</>
);
return <SelectBoardsMenu />;
}
return (
<>
{isEditMode && <AddMenu />}
<QueryRefreshIndicator />
<EditModeMenu />
<HeaderButton href={`/boards/${board.name}/settings`}>
@@ -1,35 +1,48 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Box, Group, HoverCard, Indicator, ScrollArea, Stack, Text } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import { forwardRef, useEffect, useState } from "react";
import { Box, Indicator, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { HeaderButton } from "./button";
import { useEditMode } from "@homarr/boards/edit-mode";
interface QueryStatusRow {
id: string;
label: string;
section: string;
procedure: string;
status: "pending" | "error" | "success";
fetchStatus: "fetching" | "paused" | "idle";
dataUpdatedAt: number;
isStale: boolean;
}
const widgetQueryPrefixes = new Set(["widget", "docker", "app", "integration"]);
const trackedQueryPrefixes = new Set(["widget", "integration"]);
const isWidgetQuery = (queryKey: readonly unknown[]): boolean => {
const isTrackedQuery = (queryKey: readonly unknown[]): boolean => {
const first = queryKey[0];
return Array.isArray(first) && first.length >= 1 && widgetQueryPrefixes.has(first[0]);
return Array.isArray(first) && first.length >= 1 && trackedQueryPrefixes.has(first[0] as string);
};
const extractQueryLabel = (queryKey: readonly unknown[]): string => {
const sectionLabels: Record<string, string> = {
widget: "Widgets",
integration: "Integrations",
};
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const parseQueryKey = (queryKey: readonly unknown[]): { section: string; procedure: string } => {
const first = queryKey[0];
if (Array.isArray(first)) {
return first.filter((s) => typeof s === "string").join(".");
}
return String(first);
if (!Array.isArray(first)) return { section: "Other", procedure: String(first) };
const parts = first.filter((s): s is string => typeof s === "string");
const sectionKey = parts[0] ?? "other";
const section = sectionLabels[sectionKey] ?? capitalize(sectionKey);
const remaining = parts.slice(1);
const procedure = remaining.length > 0 ? remaining.map(capitalize).join(" — ") : sectionKey;
return { section, procedure };
};
const statusColorMap: Record<string, string> = {
@@ -55,7 +68,7 @@ function useQueryCacheStatus() {
useEffect(() => {
const update = () => {
const cache = queryClient.getQueryCache();
const queries = cache.findAll({ type: "active" }).filter((q) => isWidgetQuery(q.queryKey));
const queries = cache.findAll({ type: "active" }).filter((q) => isTrackedQuery(q.queryKey));
let fetching = 0;
let errors = 0;
@@ -63,15 +76,17 @@ function useQueryCacheStatus() {
const nextRows: QueryStatusRow[] = queries.map((q) => {
if (q.state.fetchStatus === "fetching") fetching++;
if (q.state.status === "error") errors++;
const isStale = q.isStale();
if (isStale) stale++;
const isQueryStale = q.isStale();
if (isQueryStale) stale++;
const { section, procedure } = parseQueryKey(q.queryKey);
return {
id: q.queryHash,
label: extractQueryLabel(q.queryKey),
section,
procedure,
status: q.state.status,
fetchStatus: q.state.fetchStatus,
dataUpdatedAt: q.state.dataUpdatedAt,
isStale,
isStale: isQueryStale,
};
});
@@ -98,117 +113,87 @@ function useQueryCacheStatus() {
return { ...snapshot, totalCount: snapshot.rows.length };
}
const audioCtxRef = { current: null as AudioContext | null };
function useCompletionBeep(fetchingCount: number) {
const prevRef = useRef(fetchingCount);
useEffect(() => {
const wasFetching = prevRef.current > 0;
const nowIdle = fetchingCount === 0;
prevRef.current = fetchingCount;
if (!wasFetching || !nowIdle) return;
try {
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
audioCtxRef.current = new AudioContext();
}
const ctx = audioCtxRef.current;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.value = 0.08;
osc.start();
osc.stop(ctx.currentTime + 0.08);
} catch {
// audio blocked before user interaction
function groupBySection(rows: QueryStatusRow[]): Map<string, QueryStatusRow[]> {
const groups = new Map<string, QueryStatusRow[]>();
for (const row of rows) {
const existing = groups.get(row.section);
if (existing) {
existing.push(row);
} else {
groups.set(row.section, [row]);
}
}, [fetchingCount]);
}
return groups;
}
function useGreenFlash(fetchingCount: number) {
const [flash, setFlash] = useState(false);
const prevRef = useRef(fetchingCount);
useEffect(() => {
const wasFetching = prevRef.current > 0;
const nowIdle = fetchingCount === 0;
prevRef.current = fetchingCount;
if (!wasFetching || !nowIdle) return;
setFlash(true);
const timer = setTimeout(() => setFlash(false), 1500);
return () => clearTimeout(timer);
}, [fetchingCount]);
return flash;
}
const StatusDot = forwardRef<HTMLButtonElement, { color: string; processing: boolean }>(
({ color, processing, ...others }, ref) => (
<UnstyledButton ref={ref} {...others} style={{ lineHeight: 0 }}>
<Indicator color={color} size={10} processing={processing} withBorder>
<Box w={0} h={0} />
</Indicator>
</UnstyledButton>
),
);
export const QueryRefreshIndicator = () => {
const { rows, widgetFetchingCount, errorCount, staleCount, totalCount } = useQueryCacheStatus();
const showGreen = useGreenFlash(widgetFetchingCount);
useCompletionBeep(widgetFetchingCount);
const [isEditMode] = useEditMode();
const { rows, widgetFetchingCount, errorCount, totalCount } = useQueryCacheStatus();
if (totalCount === 0) return null;
if (isEditMode || totalCount === 0) return null;
const isFetching = widgetFetchingCount > 0;
const color = isFetching ? "orange" : showGreen ? "green" : errorCount > 0 ? "red" : "green";
const color = isFetching ? "orange" : errorCount > 0 ? "red" : "green";
const grouped = groupBySection(rows);
return (
<HoverCard width={360} position="bottom-end" shadow="md" withArrow>
<HoverCard.Target>
<Indicator color={color} size={8} processing={isFetching} withBorder>
<HeaderButton>
<IconDatabase stroke={1.5} size={20} />
</HeaderButton>
</Indicator>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap="xs">
<Group justify="apart">
<Text size="sm" fw={600}>
Integration status
</Text>
<Text size="xs" c="dimmed">
{totalCount} queries &middot; {widgetFetchingCount} fetching, {staleCount} stale, {errorCount} errors
</Text>
</Group>
<ScrollArea.Autosize mah={240}>
<Stack gap={4}>
{rows.map((row) => (
<QueryRow key={row.id} row={row} />
<Menu width={320} position="bottom-start" shadow="md" withArrow>
<Menu.Target>
<StatusDot color={color} processing={isFetching} />
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize mah={360}>
{Array.from(grouped).map(([section, sectionRows], i) => (
<div key={section}>
{i > 0 && <Menu.Divider />}
<Menu.Label>{section}</Menu.Label>
{sectionRows.map((row) => (
<QueryMenuItem key={row.id} row={row} />
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</div>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
};
const QueryRow = ({ row }: { row: QueryStatusRow }) => {
const rowColor = statusColorMap[row.fetchStatus === "fetching" ? "fetching" : `idle_${row.status}`] ?? "gray";
const QueryMenuItem = ({ row }: { row: QueryStatusRow }) => {
const rowColor = statusColorMap[
row.fetchStatus === "fetching" ? "fetching" : `idle_${row.status}`
] ?? "gray";
const updatedLabel = row.dataUpdatedAt > 0 ? dayjs(row.dataUpdatedAt).fromNow() : "never";
return (
<Group gap="xs" wrap="nowrap">
<Box
w={8}
h={8}
style={{ borderRadius: "50%", flexShrink: 0, backgroundColor: `var(--mantine-color-${rowColor}-6)` }}
/>
<Text size="xs" lineClamp={1} style={{ flex: 1 }}>
{row.label}
<Menu.Item
leftSection={
<Box
w={8}
h={8}
style={{ borderRadius: "50%", flexShrink: 0, backgroundColor: `var(--mantine-color-${rowColor}-6)` }}
/>
}
rightSection={
<Text size="xs" c="dimmed">
{updatedLabel}
</Text>
}
style={{ cursor: "default" }}
>
<Text size="xs" lineClamp={1}>
{row.procedure}
</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{updatedLabel}
</Text>
</Group>
</Menu.Item>
);
};
@@ -1,7 +1,10 @@
"use client";
import { Group } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import { QueryRefreshIndicator } from "../header/query-refresh-indicator";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
@@ -33,11 +36,14 @@ export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTi
const board = useRequiredBoard();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle}
image={imageOptions}
/>
<Group gap="xs" wrap="nowrap" align="center">
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle}
image={imageOptions}
/>
<QueryRefreshIndicator />
</Group>
);
};