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:
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 · {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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user