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