feat: port release-v2 experience improvements from #6582 (audited subset) (#6609)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-18 09:40:02 +02:00
1 parent e3a713d2df
commit e842177d9b
163 files changed
+7390 -1240

No files matched your search

+73 -29
View File
@@ -35,10 +35,10 @@ import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -50,6 +50,7 @@ import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/us
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
type ContainerAction = "start" | "stop" | "restart" | "remove";
interface ContextMenuState {
@@ -59,6 +60,7 @@ interface ContextMenuState {
}
interface ContainerActionHandlers {
canUse: (container: DockerContainer, capability: DockerEndpointCapability) => boolean;
onAction: (action: ContainerAction, container: DockerContainer) => void;
onAddToHomarr: (container: DockerContainer) => void;
onOpenLogs: (container: DockerContainer) => void;
@@ -67,8 +69,10 @@ interface ContainerActionHandlers {
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -209,10 +213,11 @@ const useContainerAction = (action: ContainerAction) => {
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
onSuccess(results) {
const failed = results.some((result) => !result.success);
(failed ? showErrorNotification : showSuccessNotification)({
title: t(`${action}.notification.${failed ? "error" : "success"}.title`),
message: t(`${action}.notification.${failed ? "error" : "success"}.message`),
});
},
onError() {
@@ -236,14 +241,31 @@ export default function DockerWidget({
const t = useScopedI18n("docker");
const tWidget = useScopedI18n("widget.dockerContainers");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const confirmRemoval = useDockerContainerRemovalConfirmation();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils();
const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery);
const { refetch, isFetching } = containersQuery;
const { isFetching } = containersQuery;
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
},
onError() {
showErrorNotification({
title: t("action.refresh.notification.error.title"),
message: t("action.refresh.notification.error.message"),
});
},
});
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -254,12 +276,13 @@ export default function DockerWidget({
const { mutate: removeContainer } = useContainerAction("remove");
const handleContainerAction = useCallback(
(action: ContainerAction, container: DockerContainer) => {
if (action === "start") startContainer({ ids: [container.id] });
else if (action === "stop") stopContainer({ ids: [container.id] });
else if (action === "restart") restartContainer({ ids: [container.id] });
else removeContainer({ ids: [container.id] });
const target = { targets: [getContainerTarget(container)] };
if (action === "start") startContainer(target);
else if (action === "stop") stopContainer(target);
else if (action === "restart") restartContainer(target);
else confirmRemoval([container], () => removeContainer(target));
},
[removeContainer, restartContainer, startContainer, stopContainer],
[confirmRemoval, removeContainer, restartContainer, startContainer, stopContainer],
);
const handleOpenLogs = useCallback(
(container: DockerContainer) => window.location.assign(createContainerLogsPath(container)),
@@ -271,11 +294,16 @@ export default function DockerWidget({
);
const actionHandlers = useMemo<ContainerActionHandlers>(
() => ({
canUse: (container, capability) =>
endpointHasCapability(
data?.endpoints.find(({ id }) => id === container.endpointId),
capability,
),
onAction: handleContainerAction,
onAddToHomarr: handleAddToHomarr,
onOpenLogs: handleOpenLogs,
}),
[handleAddToHomarr, handleContainerAction, handleOpenLogs],
[data?.endpoints, handleAddToHomarr, handleContainerAction, handleOpenLogs],
);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
@@ -359,6 +387,14 @@ export default function DockerWidget({
);
}
if (containers.length === 0 && data?.endpoints.some(({ status }) => status === "unavailable")) {
return (
<Center h="100%">
<Text>{tWidget("error.endpointsUnavailable")}</Text>
</Center>
);
}
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
return (
@@ -376,7 +412,7 @@ export default function DockerWidget({
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
idAccessor="resourceId"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
if (contextMenu) closeContextMenu();
@@ -417,8 +453,8 @@ export default function DockerWidget({
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
loading={isFetching || refreshInventory.isPending}
onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh size={16} />
@@ -475,7 +511,6 @@ function ContainerActionItems({
onClose: () => void;
}) {
const t = useScopedI18n("docker.action");
const [confirmRemove, setConfirmRemove] = useState(false);
const stateAction = container.state === "running" ? "stop" : "start";
const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay;
@@ -491,6 +526,7 @@ function ContainerActionItems({
</Menu.Label>
<Menu.Item
leftSection={<IconFileText size={14} />}
disabled={!handlers.canUse(container, "logs")}
onClick={() => {
handlers.onOpenLogs(container);
onClose();
@@ -502,22 +538,27 @@ function ContainerActionItems({
<Menu.Item
color={stateAction === "start" ? "green" : "red"}
leftSection={<StateIcon size={14} />}
disabled={!handlers.canUse(container, "lifecycle")}
onClick={() => invokeAction(stateAction)}
>
{t(`${stateAction}.label`)}
</Menu.Item>
<Menu.Item color="orange" leftSection={<IconRotateClockwise size={14} />} onClick={() => invokeAction("restart")}>
<Menu.Item
color="orange"
leftSection={<IconRotateClockwise size={14} />}
disabled={!handlers.canUse(container, "lifecycle")}
onClick={() => invokeAction("restart")}
>
{t("restart.label")}
</Menu.Item>
{!confirmRemove ? (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => setConfirmRemove(true)}>
{t("remove.label")}
</Menu.Item>
) : (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => invokeAction("remove")}>
{t("remove.confirm")}
</Menu.Item>
)}
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
disabled={!handlers.canUse(container, "remove")}
onClick={() => invokeAction("remove")}
>
{t("remove.label")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size={14} />}
@@ -532,6 +573,9 @@ function ContainerActionItems({
);
}
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
function ContainerContextMenu({
state,
handlers,
+116 -33
View File
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
@@ -13,6 +14,7 @@ import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { getInputForType } from "../_inputs";
@@ -44,8 +46,15 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
itemId?: string;
boardId?: string;
appId?: string;
onEditIntegration?: (integrationId: string) => void;
onIntegrationSaved?: () => void;
}
export const getSelectedWidgetIntegrations = (
integrationData: readonly IntegrationSelectOption[],
selectedIds: readonly string[],
) => integrationData.filter((integration) => selectedIds.includes(integration.id));
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
@@ -61,6 +70,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const integrationsRequired =
innerProps.integrationSupport &&
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
@@ -79,13 +89,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
? z.object(optionsFieldSchema).superRefine(optionsSuperRefine)
: z.object(optionsFieldSchema);
let integrationIdsSchema = z.array(z.string());
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: optionsSchema,
integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()),
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
@@ -102,6 +116,27 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
const showResourceTabs = showAppTab || showIntegrationTab;
const [activeTab, setActiveTab] = useState<string | null>("widget");
useEffect(() => {
if (activeTab === "integration" && !showIntegrationTab) setActiveTab("widget");
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]);
const handleTabChange = (value: string | null) => {
if (value === "integration" && selectedIntegrations.length === 1) {
const integration = selectedIntegrations[0];
if (integration) {
innerProps.onEditIntegration?.(integration.id);
return;
}
}
setActiveTab(value);
};
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
@@ -132,7 +167,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
{...form.getInputProps("integrationIds")}
/>
@@ -165,20 +200,14 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
/>
);
})}
{showAppTab ? (
{showResourceTabs ? (
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
onSuccess: setAdvancedOptions,
})
}
>
@@ -194,13 +223,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
onSuccess: setAdvancedOptions,
})
}
>
@@ -215,28 +238,86 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showAppTab ? (
{showResourceTabs ? (
<Stack>
<Tabs defaultValue="widget">
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{t("item.edit.tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
<Text size="sm" c="dimmed">
{t("item.edit.integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => innerProps.onEditIntegration?.(integration.id)}
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
>
{t("item.edit.integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{t("item.edit.integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent
@@ -250,4 +331,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -8,7 +8,6 @@ import {
CloseButton,
Combobox,
Group,
Input,
Pill,
PillsInput,
Stack,
@@ -38,6 +37,7 @@ export const WidgetIntegrationSelect = ({
data,
onChange,
value: valueProp,
label,
canSelectMultiple = true,
withAsterisk = false,
...props
@@ -79,6 +79,7 @@ export const WidgetIntegrationSelect = ({
option={option}
onRemove={() => handleValueRemove(item)}
showRemoveButton={canSelectMultiple}
removeLabel={t("item.edit.field.integrations.removeLabel", { name: option.name })}
/>
);
});
@@ -92,7 +93,7 @@ export const WidgetIntegrationSelect = ({
<Avatar src={getIconUrl(item.kind)} size="sm" />
<Stack gap={0}>
<span>{item.name}</span>
<Text size="xs" c="gray.6">
<Text size="xs" c="dimmed">
{item.url}
</Text>
</Stack>
@@ -109,33 +110,42 @@ export const WidgetIntegrationSelect = ({
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs" span>
{t.rich("widget.common.integration.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("common.here")}
</Anchor>
),
})}
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("widget.common.integration.manage")}
</Anchor>
</Text>
}
pointer
onClick={() => combobox.toggleDropdown()}
onClick={() => combobox.openDropdown()}
label={label}
withAsterisk={withAsterisk}
{...props}
>
<Pill.Group>
{values.length > 0 ? values : <Input.Placeholder>{t("common.multiSelect.placeholder")}</Input.Placeholder>}
{values}
<Combobox.EventsTarget>
<PillsInput.Field
type="hidden"
readOnly
aria-label={label}
placeholder={values.length === 0 ? t("common.multiSelect.placeholder") : undefined}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key !== "Backspace") return;
event.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleValueRemove(multiSelectValues[multiSelectValues.length - 1]!);
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
combobox.openDropdown();
return;
}
if (event.key === "Escape") {
combobox.closeDropdown();
return;
}
if (event.key === "Backspace" && canSelectMultiple && multiSelectValues.length > 0) {
event.preventDefault();
const lastValue = multiSelectValues.at(-1);
if (lastValue) handleValueRemove(lastValue);
}
}}
/>
</Combobox.EventsTarget>
@@ -163,22 +173,38 @@ export interface IntegrationSelectOption {
name: string;
url: string;
kind: IntegrationKind;
permissions?: {
hasUseAccess: boolean;
hasInteractAccess: boolean;
hasFullAccess: boolean;
};
}
interface IntegrationPillProps {
option: IntegrationSelectOption;
onRemove: () => void;
showRemoveButton: boolean;
removeLabel: string;
}
const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
const IntegrationPill = ({ option, onRemove, showRemoveButton, removeLabel }: IntegrationPillProps) => (
<Group align="center" wrap="nowrap" gap={0} className={classes.pill} mih={24} pr={!showRemoveButton ? 10 : undefined}>
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
<Text span size="xs" lh={1} fw={500}>
{option.name}
</Text>
{showRemoveButton && (
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
<CloseButton
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
aria-label={removeLabel}
variant="transparent"
color="gray"
size={24}
iconSize={14}
/>
)}
</Group>
);