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

@@ -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>
);
};