Skip to content

Commit a266874

Browse files
committed
feat(ui): add global query refresh indicator to board header
Replace per-widget loading spinners with a shared header indicator that shows live TanStack Query cache status for all widget-related queries. Includes orange/green dot, completion beep, and hover card with per-query staleness and error details.
1 parent 4a67bf0 commit a266874

3 files changed

Lines changed: 228 additions & 6 deletions

File tree

apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,27 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
3737
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
3838
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
3939
import { HeaderButton } from "~/components/layout/header/button";
40+
import { QueryRefreshIndicator } from "~/components/layout/header/query-refresh-indicator";
4041

4142
export const BoardContentHeaderActions = () => {
4243
const [isEditMode] = useEditMode();
4344
const board = useRequiredBoard();
4445
const { hasChangeAccess } = useBoardPermissions(board);
4546

4647
if (!hasChangeAccess) {
47-
return <SelectBoardsMenu />;
48+
return (
49+
<>
50+
<QueryRefreshIndicator />
51+
<SelectBoardsMenu />
52+
</>
53+
);
4854
}
4955

5056
return (
5157
<>
5258
{isEditMode && <AddMenu />}
5359

60+
<QueryRefreshIndicator />
5461
<EditModeMenu />
5562

5663
<HeaderButton href={`/boards/${board.name}/settings`}>
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
import { Box, Group, HoverCard, Indicator, ScrollArea, Stack, Text } from "@mantine/core";
5+
import { IconDatabase } from "@tabler/icons-react";
6+
import { useQueryClient } from "@tanstack/react-query";
7+
import dayjs from "dayjs";
8+
9+
import { HeaderButton } from "./button";
10+
11+
interface QueryStatusRow {
12+
id: string;
13+
label: string;
14+
status: "pending" | "error" | "success";
15+
fetchStatus: "fetching" | "paused" | "idle";
16+
dataUpdatedAt: number;
17+
isStale: boolean;
18+
}
19+
20+
const widgetQueryPrefixes = new Set(["widget", "docker", "app", "integration"]);
21+
22+
const isWidgetQuery = (queryKey: readonly unknown[]): boolean => {
23+
const first = queryKey[0];
24+
return Array.isArray(first) && first.length >= 1 && widgetQueryPrefixes.has(first[0]);
25+
};
26+
27+
const extractQueryLabel = (queryKey: readonly unknown[]): string => {
28+
const first = queryKey[0];
29+
if (Array.isArray(first)) {
30+
return first.filter((s) => typeof s === "string").join(".");
31+
}
32+
return String(first);
33+
};
34+
35+
const statusColorMap: Record<string, string> = {
36+
fetching: "orange",
37+
idle_success: "green",
38+
idle_error: "red",
39+
idle_pending: "gray",
40+
};
41+
42+
interface CacheSnapshot {
43+
rows: QueryStatusRow[];
44+
widgetFetchingCount: number;
45+
errorCount: number;
46+
staleCount: number;
47+
}
48+
49+
const emptyCacheSnapshot: CacheSnapshot = { rows: [], widgetFetchingCount: 0, errorCount: 0, staleCount: 0 };
50+
51+
function useQueryCacheStatus() {
52+
const queryClient = useQueryClient();
53+
const [snapshot, setSnapshot] = useState<CacheSnapshot>(emptyCacheSnapshot);
54+
55+
useEffect(() => {
56+
const update = () => {
57+
const cache = queryClient.getQueryCache();
58+
const queries = cache.findAll({ type: "active" }).filter((q) => isWidgetQuery(q.queryKey));
59+
60+
let fetching = 0;
61+
let errors = 0;
62+
let stale = 0;
63+
const nextRows: QueryStatusRow[] = queries.map((q) => {
64+
if (q.state.fetchStatus === "fetching") fetching++;
65+
if (q.state.status === "error") errors++;
66+
const isStale = q.isStale();
67+
if (isStale) stale++;
68+
return {
69+
id: q.queryHash,
70+
label: extractQueryLabel(q.queryKey),
71+
status: q.state.status,
72+
fetchStatus: q.state.fetchStatus,
73+
dataUpdatedAt: q.state.dataUpdatedAt,
74+
isStale,
75+
};
76+
});
77+
78+
setSnapshot({ rows: nextRows, widgetFetchingCount: fetching, errorCount: errors, staleCount: stale });
79+
};
80+
81+
update();
82+
83+
let rafId: number | null = null;
84+
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
85+
if (rafId !== null) return;
86+
rafId = requestAnimationFrame(() => {
87+
rafId = null;
88+
update();
89+
});
90+
});
91+
92+
return () => {
93+
unsubscribe();
94+
if (rafId !== null) cancelAnimationFrame(rafId);
95+
};
96+
}, [queryClient]);
97+
98+
return { ...snapshot, totalCount: snapshot.rows.length };
99+
}
100+
101+
const audioCtxRef = { current: null as AudioContext | null };
102+
103+
function useCompletionBeep(fetchingCount: number) {
104+
const prevRef = useRef(fetchingCount);
105+
106+
useEffect(() => {
107+
const wasFetching = prevRef.current > 0;
108+
const nowIdle = fetchingCount === 0;
109+
prevRef.current = fetchingCount;
110+
111+
if (!wasFetching || !nowIdle) return;
112+
113+
try {
114+
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
115+
audioCtxRef.current = new AudioContext();
116+
}
117+
const ctx = audioCtxRef.current;
118+
const osc = ctx.createOscillator();
119+
const gain = ctx.createGain();
120+
osc.connect(gain);
121+
gain.connect(ctx.destination);
122+
osc.frequency.value = 880;
123+
gain.gain.value = 0.08;
124+
osc.start();
125+
osc.stop(ctx.currentTime + 0.08);
126+
} catch {
127+
// audio blocked before user interaction
128+
}
129+
}, [fetchingCount]);
130+
}
131+
132+
function useGreenFlash(fetchingCount: number) {
133+
const [flash, setFlash] = useState(false);
134+
const prevRef = useRef(fetchingCount);
135+
136+
useEffect(() => {
137+
const wasFetching = prevRef.current > 0;
138+
const nowIdle = fetchingCount === 0;
139+
prevRef.current = fetchingCount;
140+
141+
if (!wasFetching || !nowIdle) return;
142+
143+
setFlash(true);
144+
const timer = setTimeout(() => setFlash(false), 1500);
145+
return () => clearTimeout(timer);
146+
}, [fetchingCount]);
147+
148+
return flash;
149+
}
150+
151+
export const QueryRefreshIndicator = () => {
152+
const { rows, widgetFetchingCount, errorCount, staleCount, totalCount } = useQueryCacheStatus();
153+
const showGreen = useGreenFlash(widgetFetchingCount);
154+
useCompletionBeep(widgetFetchingCount);
155+
156+
if (totalCount === 0) return null;
157+
158+
const isFetching = widgetFetchingCount > 0;
159+
const color = isFetching ? "orange" : showGreen ? "green" : errorCount > 0 ? "red" : "green";
160+
161+
return (
162+
<HoverCard width={360} position="bottom-end" shadow="md" withArrow>
163+
<HoverCard.Target>
164+
<Indicator color={color} size={8} processing={isFetching} withBorder>
165+
<HeaderButton>
166+
<IconDatabase stroke={1.5} size={20} />
167+
</HeaderButton>
168+
</Indicator>
169+
</HoverCard.Target>
170+
<HoverCard.Dropdown>
171+
<Stack gap="xs">
172+
<Group justify="apart">
173+
<Text size="sm" fw={600}>
174+
Integration status
175+
</Text>
176+
<Text size="xs" c="dimmed">
177+
{totalCount} queries &middot; {widgetFetchingCount} fetching, {staleCount} stale, {errorCount} errors
178+
</Text>
179+
</Group>
180+
181+
<ScrollArea.Autosize mah={240}>
182+
<Stack gap={4}>
183+
{rows.map((row) => (
184+
<QueryRow key={row.id} row={row} />
185+
))}
186+
</Stack>
187+
</ScrollArea.Autosize>
188+
</Stack>
189+
</HoverCard.Dropdown>
190+
</HoverCard>
191+
);
192+
};
193+
194+
const QueryRow = ({ row }: { row: QueryStatusRow }) => {
195+
const rowColor = statusColorMap[
196+
row.fetchStatus === "fetching" ? "fetching" : `idle_${row.status}`
197+
] ?? "gray";
198+
199+
const updatedLabel = row.dataUpdatedAt > 0
200+
? dayjs(row.dataUpdatedAt).fromNow()
201+
: "never";
202+
203+
return (
204+
<Group gap="xs" wrap="nowrap">
205+
<Box
206+
w={8}
207+
h={8}
208+
style={{ borderRadius: "50%", flexShrink: 0, backgroundColor: `var(--mantine-color-${rowColor}-6)` }}
209+
/>
210+
<Text size="xs" lineClamp={1} style={{ flex: 1 }}>
211+
{row.label}
212+
</Text>
213+
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
214+
{updatedLabel}
215+
</Text>
216+
</Group>
217+
);
218+
};

packages/widgets/src/calendar/component.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
3939
releaseType: options.releaseType,
4040
showUnmonitored: options.showUnmonitored,
4141
};
42-
const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, {
42+
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, {
4343
refetchOnMount: false,
4444
refetchOnWindowFocus: false,
4545
refetchOnReconnect: false,
@@ -61,7 +61,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
6161
},
6262
});
6363

64-
const events = useMemo(() => data.flatMap((item) => item.events), [data]);
64+
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
6565

6666
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
6767
};
@@ -156,9 +156,6 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
156156

157157
return (
158158
<CalendarDay
159-
// new Date() does not work here, because for timezones like UTC-7 it will
160-
// show one day earlier (probably due to the time being set to 00:00)
161-
// see https://github.com/homarr-labs/homarr/pull/3120
162159
date={dayjs(tileDate).toDate()}
163160
events={eventsForDate}
164161
disabled={isEditMode || eventsForDate.length === 0}

0 commit comments

Comments
 (0)