Compare commits

..
Author SHA1 Message Date
Thomas Camlong 80b8aae9fc feat(header): add Docker quick-access button in user menu
Adds a Docker button to the user avatar dropdown menu (admin-only)
that opens a 1400px modal containing the same Docker container table
as /manage/tools/docker.

Closes #2165
2026-06-22 16:07:03 +02:00
12 changed files with 71 additions and 35 deletions

No files matched your search

@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
}, },
]; ];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n(); const t = useI18n();
const tDocker = useScopedI18n("docker"); const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, { const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp }, initialData,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data.timestamp); const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() { async onSuccess() {
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
}, },
}); });
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data: data.containers, data: containers,
state: { isLoading: isFetching },
enableDensityToggle: false, enableDensityToggle: false,
enableColumnActions: false, enableColumnActions: false,
enableColumnFilters: false, enableColumnFilters: false,
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
enableBottomToolbar: false, enableBottomToolbar: false,
positionGlobalFilter: "right", positionGlobalFilter: "right",
mantineSearchTextInputProps: { mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(data.containers.length) }), placeholder: tDocker("table.search", { count: String(containers.length) }),
style: { minWidth: 300 }, style: { minWidth: 300 },
autoFocus: true, autoFocus: true,
}, },
@@ -23,7 +23,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tDocker("title")}</Title> <Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} /> <DockerTable initialData={{ containers, timestamp }} />
</Stack> </Stack>
</> </>
); );
@@ -0,0 +1,10 @@
"use client";
import { createModal } from "@homarr/modals";
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
defaultTitle: (t) => t("docker.title"),
size: 1400,
});
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar"; import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu"; import { UserAvatarMenu } from "~/components/user-avatar-menu";
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
export const UserButton = async () => { export const UserButton = async () => {
const session = await auth(); const session = await auth();
const isAdmin = session?.user.permissions.includes("admin"); const isAdmin = session?.user.permissions.includes("admin");
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined; const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return ( return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}> <UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton> <UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}> <Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}> <UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks"; import { useHotkeys, useTimeout } from "@mantine/hooks";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react"; import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session"; import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox"; import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox"; import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
import { AvailableUpdatesMenuItem } from "./layout/header/update"; import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps { interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>; availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean;
} }
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => { export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
const { logoutUrl } = useAuthContext(); const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal); const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => { const handleSignout = useCallback(async () => {
await signOut({ await signOut({
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}> <Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")} {t("management")}
</Menu.Item> </Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
</Menu.Item>
)}
</> </>
)} )}
<Menu.Divider /> <Menu.Divider />
+16 -11
View File
@@ -75,7 +75,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
}); });
return enriched; return enriched;
}, },
cacheDuration: dayjs.duration(1, "second"), cacheDuration: dayjs.duration(5, "seconds"),
fallbackToStaleOnError: true, fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 }, retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems", queryKey: "beszelSystems",
@@ -104,19 +104,19 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
}); });
return { alerts, history }; return { alerts, history };
}, },
cacheDuration: dayjs.duration(1, "second"), cacheDuration: dayjs.duration(15, "seconds"),
fallbackToStaleOnError: true, fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 }, retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts", queryKey: "beszelAlerts",
}); });
const timePeriodConfig: Record<string, { type: string; perPage: number }> = { const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
"1m": { type: "1m", perPage: 60 }, "1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
"1h": { type: "1m", perPage: 60 }, "1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72 }, "12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72 }, "24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "480m", perPage: 21 }, "1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90 }, "30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
}; };
export interface BeszelStatsData { export interface BeszelStatsData {
@@ -131,7 +131,7 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, input) {
const start = performance.now(); const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 }; const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
const instance = await createIntegrationAsync(integration); const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker const containerStats = input.includeDocker
@@ -147,8 +147,13 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
}); });
return { systemStats, containerStats }; return { systemStats, containerStats };
}, },
cacheDuration: dayjs.duration(1, "second"), cacheDuration: dayjs.duration(60, "seconds"),
fallbackToStaleOnError: true, fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 }, retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats", queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
}); });
+2 -1
View File
@@ -1467,6 +1467,7 @@
"switchToDarkMode": "Switch to dark mode", "switchToDarkMode": "Switch to dark mode",
"switchToLightMode": "Switch to light mode", "switchToLightMode": "Switch to light mode",
"management": "Management", "management": "Management",
"docker": "Docker",
"preferences": "Your preferences", "preferences": "Your preferences",
"logout": "Logout", "logout": "Logout",
"login": "Login", "login": "Login",
@@ -4295,7 +4296,7 @@
"notStarted": "Not started" "notStarted": "Not started"
}, },
"name": { "name": {
"management": "Management", "management": "Management",
"dashboard": "Dashboard" "dashboard": "Dashboard"
} }
} }
@@ -46,9 +46,8 @@ export default function BeszelAlertsWidget({
error: alertsError, error: alertsError,
isPending, isPending,
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, { } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
staleTime: 10_000, staleTime: 15_000,
gcTime: 48 * 60 * 60 * 1000, refetchInterval: 15_000,
refetchInterval: 10_000,
retry: false, retry: false,
}); });
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -371,7 +371,7 @@ export default function BeszelSystemGridWidget({
isPending, isPending,
} = clientApi.widget.beszel.getSystems.useQuery( } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
useBeszelSystemsSubscription(integrationIds, !isEditMode); useBeszelSystemsSubscription(integrationIds, !isEditMode);
@@ -67,10 +67,7 @@ export default function BeszelSystemStatsWidget({
data: systemsResult = [], data: systemsResult = [],
isPending: systemsPending, isPending: systemsPending,
error: systemsError, error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery( } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { staleTime: 30 * 1000, retry: false });
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, retry: false },
);
const systems = useMemo( const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
@@ -103,9 +100,8 @@ export default function BeszelSystemStatsWidget({
includeDocker: showDocker, includeDocker: showDocker,
}, },
{ {
staleTime: 10_000, staleTime: 30 * 1000,
gcTime: 48 * 60 * 60 * 1000, refetchInterval: isLive ? false : 5_000,
refetchInterval: isLive ? false : 10_000,
enabled: !isLive && systemReady && selectedSystem !== "", enabled: !isLive && systemReady && selectedSystem !== "",
retry: false, retry: false,
}, },
@@ -76,7 +76,7 @@ export default function BeszelSystemTableWidget({
isPending, isPending,
} = clientApi.widget.beszel.getSystems.useQuery( } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
const size = getSizeConfig(width); const size = getSizeConfig(width);
@@ -43,7 +43,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod, timePeriod,
includeDocker: false, includeDocker: false,
}, },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 }, { staleTime: 30_000 },
); );
const mappers = useMemo( const mappers = useMemo(