diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 3b6ea7a37..382b594b6 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -37,6 +37,7 @@ 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();
@@ -44,13 +45,19 @@ export const BoardContentHeaderActions = () => {
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
return (
<>
{isEditMode && }
+
diff --git a/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx b/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx
new file mode 100644
index 000000000..04678b736
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx
@@ -0,0 +1,218 @@
+"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 { useQueryClient } from "@tanstack/react-query";
+import dayjs from "dayjs";
+
+import { HeaderButton } from "./button";
+
+interface QueryStatusRow {
+ id: string;
+ label: string;
+ status: "pending" | "error" | "success";
+ fetchStatus: "fetching" | "paused" | "idle";
+ dataUpdatedAt: number;
+ isStale: boolean;
+}
+
+const widgetQueryPrefixes = new Set(["widget", "docker", "app", "integration"]);
+
+const isWidgetQuery = (queryKey: readonly unknown[]): boolean => {
+ const first = queryKey[0];
+ return Array.isArray(first) && first.length >= 1 && widgetQueryPrefixes.has(first[0]);
+};
+
+const extractQueryLabel = (queryKey: readonly unknown[]): string => {
+ const first = queryKey[0];
+ if (Array.isArray(first)) {
+ return first.filter((s) => typeof s === "string").join(".");
+ }
+ return String(first);
+};
+
+const statusColorMap: Record = {
+ fetching: "orange",
+ idle_success: "green",
+ idle_error: "red",
+ idle_pending: "gray",
+};
+
+interface CacheSnapshot {
+ rows: QueryStatusRow[];
+ widgetFetchingCount: number;
+ errorCount: number;
+ staleCount: number;
+}
+
+const emptyCacheSnapshot: CacheSnapshot = { rows: [], widgetFetchingCount: 0, errorCount: 0, staleCount: 0 };
+
+function useQueryCacheStatus() {
+ const queryClient = useQueryClient();
+ const [snapshot, setSnapshot] = useState(emptyCacheSnapshot);
+
+ useEffect(() => {
+ const update = () => {
+ const cache = queryClient.getQueryCache();
+ const queries = cache.findAll({ type: "active" }).filter((q) => isWidgetQuery(q.queryKey));
+
+ let fetching = 0;
+ let errors = 0;
+ let stale = 0;
+ 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++;
+ return {
+ id: q.queryHash,
+ label: extractQueryLabel(q.queryKey),
+ status: q.state.status,
+ fetchStatus: q.state.fetchStatus,
+ dataUpdatedAt: q.state.dataUpdatedAt,
+ isStale,
+ };
+ });
+
+ setSnapshot({ rows: nextRows, widgetFetchingCount: fetching, errorCount: errors, staleCount: stale });
+ };
+
+ update();
+
+ let rafId: number | null = null;
+ const unsubscribe = queryClient.getQueryCache().subscribe(() => {
+ if (rafId !== null) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ update();
+ });
+ });
+
+ return () => {
+ unsubscribe();
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };
+ }, [queryClient]);
+
+ 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
+ }
+ }, [fetchingCount]);
+}
+
+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;
+}
+
+export const QueryRefreshIndicator = () => {
+ const { rows, widgetFetchingCount, errorCount, staleCount, totalCount } = useQueryCacheStatus();
+ const showGreen = useGreenFlash(widgetFetchingCount);
+ useCompletionBeep(widgetFetchingCount);
+
+ if (totalCount === 0) return null;
+
+ const isFetching = widgetFetchingCount > 0;
+ const color = isFetching ? "orange" : showGreen ? "green" : errorCount > 0 ? "red" : "green";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Integration status
+
+
+ {totalCount} queries · {widgetFetchingCount} fetching, {staleCount} stale, {errorCount} errors
+
+
+
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+
+
+
+ );
+};
+
+const QueryRow = ({ 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 (
+
+
+
+ {row.label}
+
+
+ {updatedLabel}
+
+
+ );
+};
diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx
index b287d2324..1ee33f68a 100644
--- a/packages/widgets/src/calendar/component.tsx
+++ b/packages/widgets/src/calendar/component.tsx
@@ -39,7 +39,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
- const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, {
+ const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -61,7 +61,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
},
});
- const events = useMemo(() => data.flatMap((item) => item.events), [data]);
+ const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return ;
};
@@ -156,9 +156,6 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
return (