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
homarr-crowdin[bot] 58cb65cb3a chore(lang): updated translations from crowdin (#6029) 2026-06-22 15:23:27 +02:00
Matt Van Horn 89380f81dd feat: ship source maps for readable production stack traces (#6031) 2026-06-22 15:22:54 +02:00
Thomas e01571b221 fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering (#6051) 2026-06-22 15:22:25 +02:00
Thomasandajnart 49c266374d fix(beszel): harden cache layer and prevent blank widgets (#6052)
* fix(beszel): harden cache layer and prevent blank widgets

- Add debug logging across Beszel integration, request handler, and tRPC
  router (auth, fetch, SSE, per-procedure entry/exit) gated behind
  LOG_LEVEL=debug
- cached-request-handler: retry with backoff, isValid predicate to skip
  caching invalid data, in-flight dedup (single-flight per channel) to
  avoid thundering herd after cache invalidation
- redis channel: set-then-publish so storage is always consistent even
  when pubsub publish fails
- Beszel handlers: opt into fallbackToStaleOnError and retry so brief
  upstream hiccups don't throw away good cached data
- beszel-system-stats: add refetchInterval matching each time period's
  cache TTL so historical charts refresh in the background
- tRPC router: switch Promise.all -> Promise.allSettled for getSystems
  and getAlerts; getSystemStats returns an error object instead of
  throwing. Each result carries an optional error field so one failed
  integration no longer nukes the whole widget
- Widgets: add explicit isPending loader, empty state for grid and
  stats, and a shared per-integration error indicator (tooltip with
  failed integration names). Alerts and table now only show their
  empty state after loading completes
- Translation: add empty.noSystems key to beszelSystemGrid and
  beszelSystemStats

Addresses #6030

* style: auto-fix lint and format

---------

Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-06-22 15:20:12 +02:00
15 changed files with 915 additions and 826 deletions

No files matched your search

+14
View File
@@ -20,6 +20,17 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
@@ -64,6 +75,9 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials' ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false' ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production' ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"] CMD ["sh", "run.sh"]
+4
View File
@@ -31,6 +31,8 @@ const nextConfig: NextConfig = {
}, },
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now // react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true, //reactCompiler: true,
/** We already do typechecking as separate tasks in CI */ /** We already do typechecking as separate tasks in CI */
@@ -41,6 +43,8 @@ const nextConfig: NextConfig = {
*/ */
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: { experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true, turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false, preloadEntriesOnStart: false,
@@ -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 />
+25 -9
View File
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
}) })
.input(iconsFindSchema) .input(iconsFindSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
return { return {
icons: await ctx.db.query.iconRepositories.findMany({ icons: await ctx.db.query.iconRepositories.findMany({
with: { with: {
icons: { icons: {
columns: { columns: { id: true, name: true, url: true },
id: true, where: whereCondition,
name: true, orderBy: (table, { asc, sql }) => {
url: true, // ponytail: SVG first, then alphabetical; with a search term,
// prepend a relevance tier (exact > prefix > substring).
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
}, },
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup, limit: input.limitPerGroup,
}, },
}, },
@@ -1,8 +1,11 @@
"use client";
import type { FocusEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { startTransition } from "react"; import { startTransition, useEffect, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Box, Box,
Button,
Card, Card,
Combobox, Combobox,
Flex, Flex,
@@ -18,7 +21,7 @@ import {
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react"; import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -34,7 +37,6 @@ interface IconPickerProps {
error?: string | null; error?: string | null;
onFocus?: FocusEventHandler; onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler; onBlur?: FocusEventHandler;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
withAsterisk?: boolean; withAsterisk?: boolean;
@@ -50,29 +52,30 @@ export const IconPicker = ({
label, label,
placeholder, placeholder,
}: IconPickerProps) => { }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
value: propsValue, const [query, setQuery] = useState("");
onChange,
}); // ponytail: reset the typed query when the committed value changes
const [search, setSearch] = useUncontrolled({ // externally (form reset, parent re-render) so the input reverts to the URL.
value, useEffect(() => {
onChange: (value) => { setQuery("");
setValue(value); }, [value]);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession(); const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const [debouncedSearch] = useDebouncedValue(search, 100); // ponytail: only search when the typed query differs from the committed
// value. On mount the input shows the existing URL (edit mode), which
// would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX. // ponytail: not useSuspenseQuery — an above Suspense boundary would
const { data } = clientApi.icon.findIcons.useQuery({ // trigger on every keystroke and the dropdown flash is bad UX.
searchText: debouncedSearch, const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
}); { searchText },
{ placeholderData: (prev) => prev },
);
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
@@ -90,25 +93,15 @@ export const IconPicker = ({
> >
<UnstyledButton <UnstyledButton
onClick={() => { onClick={() => {
const value = item.url;
startTransition(() => { startTransition(() => {
setPreviewUrl(value); setValue(item.url);
setSearch(value); setQuery("");
setValue(value);
combobox.closeDropdown(); combobox.closeDropdown();
}); });
}} }}
> >
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}> <Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card <Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
>
<Box w={25} h={25}> <Box w={25} h={25}>
<Image src={item.url} w={25} h={25} /> <Image src={item.url} w={25} h={25} />
</Box> </Box>
@@ -130,6 +123,48 @@ export const IconPicker = ({
); );
}); });
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return ( return (
<Combobox store={combobox} withinPortal> <Combobox store={combobox} withinPortal>
<Combobox.Target> <Combobox.Target>
@@ -137,16 +172,11 @@ export const IconPicker = ({
<InputBase <InputBase
flex={1} flex={1}
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
leftSection={ leftSection={leftSection}
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null value={inputValue}
}
value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); combobox.openDropdown();
combobox.updateSelectedOptionIndex(); setQuery(event.currentTarget.value);
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}} }}
onClick={() => combobox.openDropdown()} onClick={() => combobox.openDropdown()}
onFocus={(event) => { onFocus={(event) => {
@@ -156,25 +186,21 @@ export const IconPicker = ({
onBlur={(event) => { onBlur={(event) => {
onBlur?.(event); onBlur?.(event);
combobox.closeDropdown(); combobox.closeDropdown();
setPreviewUrl(value); setQuery("");
setSearch(value || "");
}} }}
rightSectionPointerEvents="none" rightSectionPointerEvents="none"
withAsterisk={withAsterisk} withAsterisk={withAsterisk}
error={error} error={error}
label={label ?? tCommon("iconPicker.label")} label={label ?? tCommon("iconPicker.label")}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })} placeholder={placeholderText}
/> />
{session?.user.permissions.includes("media-upload") && ( {session?.user.permissions.includes("media-upload") && (
<UploadMedia <UploadMedia
onSuccess={(medias) => { onSuccess={(medias) => {
const first = medias.at(0); const first = medias.at(0);
if (!first) return; if (!first) return;
startTransition(() => { startTransition(() => {
setValue(first.url); setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
}); });
}} }}
> >
@@ -190,17 +216,7 @@ export const IconPicker = ({
<Combobox.Dropdown> <Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}> <Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? ( {renderDropdown()}
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
@@ -209,8 +225,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change // This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070 // See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */); const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null): value is string => { const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false; if (!value) return false;
return !localizationPathRegex.test(value); return !localizationPathRegex.test(value);
}; };
+7 -7
View File
@@ -3374,27 +3374,27 @@
"pausedCount": "Pausiert" "pausedCount": "Pausiert"
}, },
"error": { "error": {
"internalServerError": "" "internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
} }
}, },
"audioStats": { "audioStats": {
"name": "Audiostatistiken", "name": "Audiostatistiken",
"description": "", "description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
"option": { "option": {
"showArtists": { "showArtists": {
"label": "Künstler anzeigen", "label": "Künstler anzeigen",
"description": "" "description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
}, },
"showAlbums": { "showAlbums": {
"label": "", "label": "Alben anzeigen",
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)" "description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
}, },
"showSongs": { "showSongs": {
"label": "Songs anzeigen", "label": "Songs anzeigen",
"description": "" "description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
}, },
"showNowPlaying": { "showNowPlaying": {
"label": "", "label": "\"Sie hören\" anzeigen",
"description": "" "description": ""
}, },
"showNowPlayingList": { "showNowPlayingList": {
@@ -3720,7 +3720,7 @@
}, },
"chart": { "chart": {
"cpu": { "cpu": {
"title": "CPU-Auslastung", "title": "CPU Auslastung",
"subtitle": "", "subtitle": "",
"series": "CPU" "series": "CPU"
}, },
+33 -33
View File
@@ -177,7 +177,7 @@
}, },
"password": { "password": {
"label": "Password", "label": "Password",
"suggestionsTitle": "", "suggestionsTitle": "Password suggestions",
"suggestion": { "suggestion": {
"length": "Includes a minimum of 8 characters", "length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter", "lowercase": "Includes a lower-case letter",
@@ -687,28 +687,28 @@
"customWidget": { "customWidget": {
"page": { "page": {
"list": { "list": {
"title": "", "title": "Custom Widgets",
"noResults": "", "noResults": "No custom widgets yet. Create your first one!",
"loading": "" "loading": "Loading..."
}, },
"create": { "create": {
"title": "", "title": "Create Custom Widget",
"subtitle": "" "subtitle": "Define an API URL, authentication, and how to display the response data."
}, },
"edit": { "edit": {
"title": "" "title": "Edit Custom Widget"
} }
}, },
"action": { "action": {
"create": "", "create": "Create",
"import": "", "import": "Import",
"save": "", "save": "Save",
"delete": "", "delete": "Delete Custom Widget",
"deleteConfirm": "", "deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"export": "", "export": "Export",
"addMapping": "", "addMapping": "Add Mapping",
"addColumn": "", "addColumn": "Add Column",
"edit": "", "edit": "Edit",
"removeMapping": "", "removeMapping": "",
"removeColumn": "", "removeColumn": "",
"addItem": "", "addItem": "",
@@ -5599,71 +5599,71 @@
"label": "", "label": "",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select your colour scheme"
} }
} }
}, },
"locale": { "locale": {
"label": "", "label": "Language",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select your preferred language"
} }
} }
}, },
"defaultSearchEngineId": { "defaultSearchEngineId": {
"label": "", "label": "Default search engine",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select your default search engine"
} }
} }
}, },
"openSearchInNewTab": { "openSearchInNewTab": {
"label": "", "label": "Open search results in new tab",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Open search results in new tab"
} }
} }
}, },
"ddgBangs": { "ddgBangs": {
"label": "", "label": "Enable DuckDuckGo bangs",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Enable DuckDuckGo bangs"
} }
} }
}, },
"firstDayOfWeek": { "firstDayOfWeek": {
"label": "", "label": "First day of the week",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select the first day of the week"
} }
} }
}, },
"homeBoardId": { "homeBoardId": {
"label": "", "label": "Home board",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select your home board"
} }
} }
}, },
"mobileHomeBoardId": { "mobileHomeBoardId": {
"label": "", "label": "Mobile home board",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Select your mobile home board"
} }
} }
}, },
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "", "label": "Use icons for pings",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Use icons for pings"
} }
} }
}, },
+6 -2
View File
@@ -1412,7 +1412,10 @@
"here": "here", "here": "here",
"iconPicker": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you." "header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
}, },
"colorScheme": { "colorScheme": {
"options": { "options": {
@@ -1464,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",
@@ -4292,7 +4296,7 @@
"notStarted": "Not started" "notStarted": "Not started"
}, },
"name": { "name": {
"management": "Management", "management": "Management",
"dashboard": "Dashboard" "dashboard": "Dashboard"
} }
} }
+2 -2
View File
@@ -4943,8 +4943,8 @@
"search": "Cerca {count} container", "search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati", "selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}", "footer": "Totale container {count}",
"totalCpu": "", "totalCpu": "CPU Totale: {cpu}%",
"totalMemory": "" "totalMemory": "Mem Totale: {memory}"
}, },
"field": { "field": {
"name": { "name": {
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -687,7 +687,7 @@
"customWidget": { "customWidget": {
"page": { "page": {
"list": { "list": {
"title": "", "title": "Власні віджети",
"noResults": "", "noResults": "",
"loading": "" "loading": ""
}, },
@@ -1021,7 +1021,7 @@
"mediaMonitoring": "", "mediaMonitoring": "",
"speedtest": "", "speedtest": "",
"analytics": "", "analytics": "",
"mediaLibrary": "", "mediaLibrary": "Медіа бібліотека",
"uptimeMonitoring": "" "uptimeMonitoring": ""
}, },
"testConnection": { "testConnection": {
@@ -1139,7 +1139,7 @@
"description": { "description": {
"connection": { "connection": {
"hostUnreachable": "", "hostUnreachable": "",
"networkUnreachable": "", "networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
"refused": "", "refused": "",
"reset": "" "reset": ""
}, },
@@ -1328,7 +1328,7 @@
} }
}, },
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "Безпаперовий ngx",
"description": "" "description": ""
} }
}, },
@@ -1679,9 +1679,9 @@
"label": "", "label": "",
"option": { "option": {
"row": "", "row": "",
"row-reverse": "", "row-reverse": "Горизонтально (зворотнє)",
"column": "", "column": "",
"column-reverse": "" "column-reverse": "Вертикальна (перевернута)"
} }
}, },
"descriptionDisplayMode": { "descriptionDisplayMode": {
@@ -1690,7 +1690,7 @@
"option": { "option": {
"normal": "", "normal": "",
"tooltip": "", "tooltip": "",
"hidden": "" "hidden": "Приховати"
} }
} }
}, },
@@ -1703,7 +1703,7 @@
}, },
"bookmarks": { "bookmarks": {
"name": "Закладки", "name": "Закладки",
"description": "", "description": "Показує декілька посилань додатка",
"option": { "option": {
"title": { "title": {
"label": "Назва" "label": "Назва"
@@ -1747,8 +1747,8 @@
} }
}, },
"archiveTeamWarrior": { "archiveTeamWarrior": {
"name": "", "name": "АрхівTeam Warrior",
"description": "", "description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
"option": { "option": {
"showBroadcastMessage": { "showBroadcastMessage": {
"label": "" "label": ""
@@ -1778,7 +1778,7 @@
} }
}, },
"error": { "error": {
"internalServerError": "", "internalServerError": "Не вдалося отримати резюме DNS Hole",
"integrationsDisconnected": "Немає даних, всі інтеграції відключені" "integrationsDisconnected": "Немає даних, всі інтеграції відключені"
}, },
"data": { "data": {
@@ -1808,7 +1808,7 @@
} }
}, },
"showToggleAllButtons": { "showToggleAllButtons": {
"label": "" "label": "Показати всі кнопки"
} }
}, },
"error": { "error": {