chore(release): automatic release v1.1.0

This commit is contained in:
homarr-releases[bot]
2025-01-22 19:45:14 +00:00
committed by GitHub
106 changed files with 5115 additions and 2497 deletions

2
.nvmrc
View File

@@ -1 +1 @@
22.13.0
22.13.1

View File

@@ -2,6 +2,7 @@
import "@homarr/auth/env";
import "@homarr/db/env";
import "@homarr/common/env";
import "@homarr/docker/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";

View File

@@ -23,8 +23,10 @@
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.3",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -39,18 +41,18 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.16.0",
"@mantine/core": "^7.16.0",
"@mantine/dropzone": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/modals": "^7.16.0",
"@mantine/tiptap": "^7.16.0",
"@mantine/colors-generator": "^7.16.1",
"@mantine/core": "^7.16.1",
"@mantine/dropzone": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"@mantine/modals": "^7.16.1",
"@mantine/tiptap": "^7.16.1",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-query-next-experimental": "5.64.1",
"@tabler/icons-react": "^3.29.0",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2",
"@tanstack/react-query-next-experimental": "5.64.2",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -64,9 +66,9 @@
"dotenv": "^16.4.7",
"flag-icons": "^7.3.2",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"jotai": "^2.11.1",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.5",
"next": "15.1.6",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "19.0.0",

View File

@@ -94,6 +94,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: unstable_httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
headers: createHeadersCallbackForSource("nextjs-react (json)"),
}),
}),

View File

@@ -2,6 +2,7 @@
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
@@ -9,9 +10,11 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlus,
IconReplace,
IconResize,
IconSettings,
} from "@tabler/icons-react";
@@ -49,6 +52,8 @@ export const BoardContentHeaderActions = () => {
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
<SelectBoardsMenu />
</>
);
};
@@ -151,6 +156,32 @@ const EditModeMenu = () => {
);
};
const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();

View File

@@ -15,7 +15,9 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]);
const handleSearch = React.useCallback(
@@ -29,6 +31,7 @@ export const IntegrationCreateDropdownContent = () => {
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
data-autofocus
onChange={handleSearch}
/>

View File

@@ -102,7 +102,15 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
<Menu
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent />

View File

@@ -15,6 +15,11 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
// Clipboard only works on localhost or secure connections (https)
if (url.startsWith("http://") && !url.startsWith("http://localhost")) {
return null;
}
return (
<CopyButton value={url}>
{({ copy, copied }) => (

View File

@@ -1,13 +1,28 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import {
ActionIcon,
Anchor,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
Tooltip,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -91,13 +106,14 @@ interface RowProps {
const Row = async ({ media }: RowProps) => {
const session = await auth();
const t = await getI18n();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
<TableTr>
<TableTd w={64}>
<Image
src={`/api/user-medias/${media.id}`}
src={createLocalImageUrl(media.id)}
alt={media.name}
width={64}
height={64}
@@ -121,6 +137,17 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}>
<ActionIcon
component="a"
href={createLocalImageUrl(media.id)}
target="_blank"
color="gray"
variant="subtle"
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
{canDelete && <DeleteMedia media={media} />}
</Group>
</TableTd>

View File

@@ -59,10 +59,17 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
{x509Certificates.map((cert) => (
<Card key={cert.x509.fingerprint} withBorder>
<Group wrap="nowrap">
<IconCertificate color={getMantineColor(iconColor(cert.x509.validToDate), 6)} size={32} stroke={1.5} />
<Stack flex={1} gap="xs">
<Group justify="space-between">
<Text fw={500}>{cert.x509.subject}</Text>
<IconCertificate
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{cert.x509.subject}
</Text>
<Text c="gray.6" ta="end" size="sm">
{cert.fileName}
</Text>

View File

@@ -16,7 +16,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import type { ContainerState } from "@homarr/docker";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -246,9 +246,9 @@ const containerStates = {
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<DockerContainerState, MantineColor>;
} satisfies Record<ContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (

View File

@@ -51,7 +51,10 @@ export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>

View File

@@ -83,10 +83,12 @@ export default async function EditUserPage(props: Props) {
</Box>
</Group>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>

View File

@@ -30,7 +30,9 @@ const handler = auth(async (req) => {
req,
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) {
logger.error(`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`);
logger.error(
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`,
);
},
});

View File

@@ -0,0 +1,62 @@
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface CreateItemInput {
kind: WidgetKind;
}
export const createItemCallback =
({ kind }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
...emptyPosition,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Item,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== firstSection.id) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
};

View File

@@ -0,0 +1,81 @@
import { createId } from "@homarr/db/client";
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface DuplicateItemInput {
itemId: string;
}
export const duplicateItemCallback =
({ itemId }: DuplicateItemInput) =>
(previous: Board): Board => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
if (!currentSection) return previous;
const dynamicSectionsOfCurrentSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
);
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
let sectionId = currentSection.id;
let emptyPosition = getFirstEmptyPosition(
elements,
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
currentSection.kind === "dynamic" ? currentSection.height : undefined,
{
width: itemToDuplicate.width,
height: itemToDuplicate.height,
},
);
if (!emptyPosition) {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
width: itemToDuplicate.width,
height: itemToDuplicate.height,
});
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
sectionId = firstSection.id;
}
const widget = structuredClone(itemToDuplicate);
widget.id = createId();
widget.xOffset = emptyPosition.xOffset;
widget.yOffset = emptyPosition.yOffset;
widget.sectionId = sectionId;
const result = {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== sectionId) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
return result;
};

View File

@@ -0,0 +1,25 @@
import type { Item } from "~/app/[locale]/boards/_types";
export const getFirstEmptyPosition = (
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
columnCount: number,
rowCount = 9999,
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
element.yOffset + element.height > yOffset &&
element.xOffset < xOffset + size.width &&
element.xOffset + element.width > xOffset,
);
if (!isOccupied) {
return { xOffset, yOffset };
}
}
}
return undefined;
};

View File

@@ -0,0 +1,61 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "../create-item";
import * as emptyPosition from "../empty-position";
import { createDynamicSection, createEmptySection, createItem } from "./shared";
describe("item actions create-item", () => {
test("should add it to first section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const input = {
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSection = result.sections.find((section) => section.id === "2");
const item = firstSection?.items.at(0);
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([], input.columnCount);
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const firstSection = createEmptySection("2", 0);
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
firstSection.items.push(expectedItem);
const dynamicSectionInFirst = createDynamicSection({
id: "4",
parentSectionId: "2",
yOffset: 0,
xOffset: 0,
width: 2,
height: 2,
});
const input = {
sections: [
createEmptySection("1", 2),
firstSection,
createEmptySection("3", 1),
dynamicSectionInFirst,
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSectionResult = result.sections.find((section) => section.id === "2");
const item = firstSectionResult?.items.find((item) => item.id !== "12");
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPosition from "../empty-position";
import { createEmptySection, createItem } from "./shared";
describe("item actions duplicate-item", () => {
test("should copy it in the same section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const currentSection = createEmptySection("2", 1);
const currentItem = createItem({
id: "1",
xOffset: 1,
yOffset: 3,
width: 3,
height: 2,
kind: "minecraftServerStatus",
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
});
const otherItem = createItem({
id: "2",
});
currentSection.items.push(currentItem, otherItem);
const input = {
columnCount: 10,
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
} satisfies Pick<Board, "sections" | "columnCount">;
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
const section = result.sections.find((section) => section.id === "2");
expect(section?.items.length).toBe(3);
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
expect(duplicatedItem).toEqual(
expect.objectContaining({
kind: "minecraftServerStatus",
xOffset: 5,
yOffset: 5,
width: 3,
height: 2,
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
}),
);
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
[[[" ", " ", " ", " "]], [1, 1], 0, 0],
[[["a", " ", " ", " "]], [1, 1], 1, 0],
[[[" ", "a", " ", " "]], [1, 1], 0, 0],
[
[
["a", "a", " ", " "],
["a", "a", " ", " "],
],
[1, 1],
2,
0,
],
[[["a", "a", "a", "a"]], [1, 1], 0, 1],
[
[
["a", "a", "a", "a"],
["a", "a", "a", "a"],
],
[1, 1],
0,
2,
],
[
[
["a", "a", " ", "b", "b"],
["a", "a", " ", "b", "b"],
],
[1, 2],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", " ", "b", "b"],
],
[2, 2],
2,
0,
],
[
[
["a", "a", " ", "d", "b", "b"],
["a", "a", "c", "e", "b", "b"],
],
[1, 1],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", "e", "b", "b"],
],
[2, 2],
0,
2,
],
])("should return the first empty position", (layout, size, expectedX, expectedY) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, undefined, { width: size[0]!, height: size[1]! });
expect(result).toEqual({ xOffset: expectedX, yOffset: expectedY });
});
test.each([
[[[" ", " "]], [1, 1], 0, 0, 1],
[[["a", " "]], [1, 1], 1, 0, 1],
[[["a", "a"]], [1, 1], undefined, undefined, 1],
[[["a", "a"]], [1, 1], 0, 1, 2],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
undefined,
undefined,
3,
],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
0,
2,
4,
],
[[["a", "b"]], [2, 1], 0, 1, 2],
])("should return the first empty position with limited rows", (layout, size, expectedX, expectedY, rowCount) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, rowCount, { width: size[0]!, height: size[1]! });
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
const row = layout[yOffset];
if (!row) continue;
for (let xOffset = 0; xOffset < row.length; xOffset++) {
const item = row[xOffset];
if (item === " " || !item) continue;
const existing = elements.find((element) => element.char === item);
if (existing) {
existing.height = yOffset - existing.yOffset + 1;
existing.width = xOffset - existing.xOffset + 1;
continue;
}
elements.push({
yOffset,
xOffset,
width: 1,
height: 1,
char: item,
});
}
}
return elements;
};

View File

@@ -0,0 +1,32 @@
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
id,
kind: "empty",
yOffset,
xOffset: 0,
items: [],
});
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
id: section.id ?? "0",
kind: "dynamic",
parentSectionId: section.parentSectionId ?? "0",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
items: section.items ?? [],
});
export const createItem = (item: Partial<Item>): Item => ({
id: item.id ?? "0",
width: item.width ?? 1,
height: item.height ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
kind: item.kind ?? "clock",
integrationIds: item.integrationIds ?? [],
options: item.options ?? {},
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
});

View File

@@ -1,12 +1,13 @@
import { useCallback } from "react";
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
import { duplicateItemCallback } from "./actions/duplicate-item";
interface MoveAndResizeItem {
itemId: string;
@@ -42,87 +43,19 @@ interface UpdateItemIntegrations {
newIntegrations: string[];
}
interface CreateItem {
kind: WidgetKind;
}
interface DuplicateItem {
itemId: string;
}
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const createItem = useCallback(
({ kind }: CreateItem) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
if (!lastSection) return previous;
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Omit<Item, "yOffset" | "xOffset">,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== lastSection.id) return section;
return {
...section,
items: section.items.concat(widget as unknown as Item),
};
}),
};
});
(input: CreateItemInput) => {
updateBoard(createItemCallback(input));
},
[updateBoard],
);
const duplicateItem = useCallback(
({ itemId }: DuplicateItem) => {
updateBoard((previous) => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items)
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const newItem = {
...itemToDuplicate,
id: createId(),
yOffset: undefined,
xOffset: undefined,
} satisfies Modify<Item, { yOffset?: number; xOffset?: number }>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) return section;
return {
...section,
items: section.items.concat(newItem as unknown as Item),
};
}),
};
});
({ itemId }: DuplicateItemInput) => {
updateBoard(duplicateItemCallback({ itemId }));
},
[updateBoard],
);

View File

@@ -79,7 +79,12 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
>
<Throw
error={new NoIntegrationSelectedError()}
when={widgetSupportsIntegrations && item.integrationIds.length === 0}
when={
widgetSupportsIntegrations &&
item.integrationIds.length === 0 &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} />
<Comp

View File

@@ -12,11 +12,7 @@ export const env = createEnv({
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(),
},
server: {},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
@@ -30,9 +26,8 @@ export const env = createEnv({
runtimeEnv: {
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});

View File

@@ -38,7 +38,7 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.2.3"
"undici": "7.3.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -49,9 +49,9 @@ describe("Onboarding", () => {
environment: {
AUTH_PROVIDERS: "ldap",
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
AUTH_LDAP_BASE: "",
AUTH_LDAP_BIND_DN: "",
AUTH_LDAP_BIND_PASSWORD: "",
AUTH_LDAP_BASE: "not-used",
AUTH_LDAP_BIND_DN: "not-used",
AUTH_LDAP_BIND_PASSWORD: "not-used",
},
mounts: {
"/appdata": localMountPath,

View File

@@ -40,27 +40,27 @@
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.2",
"@vitest/ui": "^3.0.2",
"@vitest/coverage-v8": "^3.0.3",
"@vitest/ui": "^3.0.3",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"semantic-release": "^24.2.1",
"testcontainers": "^10.16.0",
"testcontainers": "^10.17.1",
"turbo": "^2.3.3",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"
"vitest": "^3.0.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=22.13.0"
"node": ">=22.13.1"
},
"pnpm": {
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.2.3"
"proxmox-api>undici": "7.3.0"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"

View File

@@ -29,6 +29,7 @@
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
@@ -42,9 +43,8 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "4.0.2",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
@@ -54,7 +54,6 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.34",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"

View File

@@ -98,56 +98,59 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const dbIntegrations =
input.integrationIds.length >= 1
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
item: {
secrets: true,
items: {
with: {
section: {
columns: {
boardId: true,
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
},
userPermissions: true,
groupPermissions: true,
},
})
: [];
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**

View File

@@ -1,26 +1,24 @@
import { TRPCError } from "@trpc/server";
import type Docker from "dockerode";
import type { Container } from "dockerode";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { DockerContainerState } from "@homarr/definitions";
import { DockerSingleton } from "@homarr/docker";
import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@homarr/docker";
import { logger } from "@homarr/log";
import { createCacheChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { DockerSingleton } from "./docker-singleton";
const dockerCache = createCacheChannel<{
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[];
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[];
}>("docker-containers", 5 * 60 * 1000);
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
const result = await dockerCache
.consumeAsync(async () => {
const dockerInstances = DockerSingleton.getInstance();
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(
// Return all the containers of all the instances into only one item
dockerInstances.map(({ instance, host: key }) =>
@@ -33,8 +31,7 @@ export const dockerRouter = createTRPCRouter({
),
).then((containers) => containers.flat());
const extractImage = (container: Docker.ContainerInfo) =>
container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
likeQueries.length >= 1
@@ -151,7 +148,7 @@ const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
};
const getContainerOrThrowAsync = async (id: string) => {
const dockerInstances = DockerSingleton.getInstance();
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id)));
const foundContainer = containers.find((container) => container) ?? null;
@@ -168,21 +165,21 @@ const getContainerOrThrowAsync = async (id: string) => {
interface DockerContainer {
name: string;
id: string;
state: DockerContainerState;
state: ContainerState;
image: string;
ports: Docker.Port[];
ports: Port[];
iconUrl: string | null;
}
function sanitizeContainers(
containers: (Docker.ContainerInfo & { instance: string; iconUrl: string | null })[],
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[],
): DockerContainer[] {
return containers.map((container) => {
return {
name: container.Names[0]?.split("/")[1] ?? "Unknown",
id: container.Id,
instance: container.instance,
state: container.State as DockerContainerState,
state: container.State as ContainerState,
image: container.Image,
ports: container.Ports,
iconUrl: container.iconUrl,

View File

@@ -1,51 +0,0 @@
import Docker from "dockerode";
interface DockerInstance {
host: string;
instance: Docker;
}
export class DockerSingleton {
private static instances: DockerInstance[];
private createInstances() {
const instances: DockerInstance[] = [];
const hostVariable = process.env.DOCKER_HOST;
const portVariable = process.env.DOCKER_PORT;
if (hostVariable === undefined || portVariable === undefined) {
instances.push({ host: "socket", instance: new Docker() });
return instances;
}
const hosts = hostVariable.split(",");
const ports = portVariable.split(",");
if (hosts.length !== ports.length) {
throw new Error("The number of hosts and ports must match");
}
hosts.forEach((host, i) => {
instances.push({
host: `${host}:${ports[i]}`,
instance: new Docker({
host,
port: parseInt(ports[i] ?? "", 10),
}),
});
return instances;
});
return instances;
}
public static findInstance(key: string): DockerInstance | undefined {
return this.instances.find((instance) => instance.host === key);
}
public static getInstance(): DockerInstance[] {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!DockerSingleton.instances) {
DockerSingleton.instances = new DockerSingleton().createInstances();
}
return this.instances;
}
}

View File

@@ -404,7 +404,13 @@ export const userRouter = createTRPCRouter({
});
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.userId), "view");
// Only allow user to select boards they have access to
if (input.homeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.homeBoardId), "view");
}
if (input.mobileHomeBoardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.mobileHomeBoardId), "view");
}
await ctx.db
.update(users)

View File

@@ -1,12 +1,36 @@
import type { RssFeed } from "@homarr/cron-jobs";
import { createItemChannel } from "@homarr/redis";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import { z } from "@homarr/validation";
import { createOneItemMiddleware } from "../../middlewares/item";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.unstable_concat(createOneItemMiddleware("rssFeed")).query(async ({ input }) => {
const channel = createItemChannel<RssFeed[]>(input.itemId);
return await channel.getAsync();
}),
getFeeds: publicProcedure
.input(
z.object({
urls: z.array(z.string()),
maximumAmountPosts: z.number(),
}),
)
.query(async ({ input }) => {
const rssFeeds = await Promise.all(
input.urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return rssFeeds
.flatMap((rssFeed) => rssFeed.data.entries)
.slice(0, input.maximumAmountPosts)
.sort((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
});
}),
});

View File

@@ -2,9 +2,11 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapte
import { cookies } from "next/headers";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { formatError } from "pretty-print-error";
import { db } from "@homarr/db";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
@@ -26,15 +28,16 @@ export const createConfiguration = (
const adapter = createAdapter(db, provider);
return NextAuth({
logger: {
error: (code, ...message) => {
error: (error) => {
// Remove the big error message for failed login attempts
// as it is not useful for the user.
if (code.name === "CredentialsSignin") {
console.warn("The login attempt of a user was not successful.");
if (error.name === "CredentialsSignin") {
logger.warn("The login attempt of a user was not successful.");
return;
}
console.error(code, ...message);
logger.error(formatError(error));
logger.error(formatError(error.cause));
},
},
trustHost: true,

View File

@@ -86,4 +86,5 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
},
skipValidation,
emptyStringAsUndefined: true,
});

View File

@@ -34,8 +34,9 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.1",
"next": "15.1.5",
"next": "15.1.6",
"next-auth": "5.0.0-beta.25",
"pretty-print-error": "^1.1.2",
"react": "19.0.0",
"react-dom": "19.0.0"
},

View File

@@ -23,7 +23,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.2.3"
"undici": "7.3.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -26,4 +26,5 @@ export const env = createEnv({
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});

View File

@@ -29,10 +29,10 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"undici": "7.2.3",
"undici": "7.3.0",
"zod": "^3.24.1"
},
"devDependencies": {

View File

@@ -22,7 +22,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@extractus/feed-extractor": "^7.1.3",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",

View File

@@ -11,7 +11,6 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
import { sessionCleanupJob } from "./jobs/session-cleanup";
import { updateCheckerJob } from "./jobs/update-checker";
@@ -38,4 +37,3 @@ export const jobGroup = createCronJobGroup({
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
export type { RssFeed };

View File

@@ -1,139 +1,36 @@
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
import { extract } from "@extractus/feed-extractor";
import SuperJSON from "superjson";
import type { Modify } from "@homarr/common/types";
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import { createItemChannel } from "@homarr/redis";
import { z } from "@homarr/validation";
// This import is done that way to avoid circular dependencies.
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../widgets";
import { createCronJob } from "../lib";
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_5_MINUTES).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
const rssItems = await db.query.items.findMany({
where: eq(items.kind, "rssFeed"),
});
for (const item of itemsForIntegration) {
const options = SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options);
const itemOptions = rssItems.map((item) => SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options));
const feeds = await Promise.all(
options.feedUrls.map(async (feedUrl) => ({
feedUrl,
feed: (await extract(feedUrl, {
getExtraEntryFields: (feedEntry) => {
const media = attemptGetImageFromEntry(feedUrl, feedEntry);
if (!media) {
return {};
}
return {
enclosure: media,
};
},
})) as ExtendedFeedData,
})),
);
const channel = createItemChannel<RssFeed[]>(item.id);
await channel.publishAndUpdateLastStateAsync(feeds);
for (const option of itemOptions) {
const maxAmountPosts = typeof option.maximumAmountPosts === "number" ? option.maximumAmountPosts : 100;
for (const url of option.feedUrls) {
try {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: maxAmountPosts,
});
await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: true,
});
} catch (error) {
logger.error("Failed to update RSS feed", { url, error });
}
}
}
});
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
const media = getFirstMediaProperty(entry);
if (media !== null) {
return media;
}
return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry));
};
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
const result = regex.exec(content);
if (result == null) {
return null;
}
console.debug(
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
);
return result[0];
};
const mediaProperties = [
{
path: ["enclosure", "@_url"],
},
{
path: ["media:content", "@_url"],
},
];
/**
* The RSS and Atom standards are poorly adhered to in most of the web.
* We want to show pretty background images on the posts and therefore need to extract
* the enclosure (aka. media images). This function uses the dynamic properties defined above
* to search through the possible paths and detect valid image URLs.
* @param feedObject The object to scan for.
* @returns the value of the first path that is found within the object
*/
const getFirstMediaProperty = (feedObject: object) => {
for (const mediaProperty of mediaProperties) {
let propertyIndex = 0;
let objectAtPath: object = feedObject;
while (propertyIndex < mediaProperty.path.length) {
const key = mediaProperty.path[propertyIndex];
if (key === undefined) {
break;
}
const propertyEntries = Object.entries(objectAtPath);
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
if (!propertyEntry) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [_, propertyEntryValue] = propertyEntry;
objectAtPath = propertyEntryValue as object;
propertyIndex++;
}
const validationResult = z.string().url().safeParse(objectAtPath);
if (!validationResult.success) {
continue;
}
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
return validationResult.data;
}
return null;
};
/**
* We extend the feed with custom properties.
* This interface adds properties on top of the default ones.
*/
interface ExtendedFeedEntry extends FeedEntry {
enclosure?: string;
}
/**
* We extend the feed with custom properties.
* This interface omits the default entries with our custom definition.
*/
type ExtendedFeedData = Modify<
FeedData,
{
entries?: ExtendedFeedEntry;
}
>;
export interface RssFeed {
feedUrl: string;
feed: ExtendedFeedData;
}

View File

@@ -27,7 +27,11 @@ export const env = createEnv({
.default(drivers.betterSqlite3),
...(urlRequired
? {
DB_URL: z.string(),
DB_URL:
// Fallback to the default sqlite file path in production
process.env.NODE_ENV === "production" && isDriver("better-sqlite3")
? z.string().default("/appdata/db/db.sqlite")
: z.string().nonempty(),
}
: {}),
...(hostRequired
@@ -58,4 +62,5 @@ export const env = createEnv({
DB_PORT: process.env.DB_PORT,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});

View File

@@ -44,8 +44,8 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.8.0",
"@testcontainers/mysql": "^10.17.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.4",

View File

@@ -10,6 +10,7 @@ export type HomarrDocumentationPath =
| "/blog/2024/09/23/version-1.0"
| "/blog/2024/12/17/open-beta-1.0"
| "/blog/2024/12/31/migrate-secret-enryption-key"
| "/blog/2025/01/19/migration-guide-1.0"
| "/blog/archive"
| "/blog/authors"
| "/blog/authors/ajnart"
@@ -165,6 +166,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/home-assistant"
| "/docs/getting-started/installation/portainer"
| "/docs/getting-started/installation/qnap"
| "/docs/getting-started/installation/railway"
| "/docs/getting-started/installation/saltbox"
| "/docs/getting-started/installation/source"
| "/docs/getting-started/installation/synology"

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

1
packages/docker/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,38 @@
{
"name": "@homarr/docker",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
"./env": "./src/env.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@t3-oss/env-nextjs": "^0.11.1",
"dockerode": "^4.0.4"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.34",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,18 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
export const env = createEnv({
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(),
},
runtimeEnv: {
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});

View File

@@ -0,0 +1,10 @@
import type Docker from "dockerode";
export type { DockerInstance } from "./singleton";
export { DockerSingleton } from "./singleton";
export type { ContainerInfo, Container, Port } from "dockerode";
export type { Docker };
export const containerStates = ["created", "running", "paused", "restarting", "exited", "removing", "dead"] as const;
export type ContainerState = (typeof containerStates)[number];

View File

@@ -0,0 +1,53 @@
import Docker from "dockerode";
import { env } from "./env";
export interface DockerInstance {
host: string;
instance: Docker;
}
export class DockerSingleton {
private static instances: DockerInstance[] | null = null;
private createInstances() {
const hostVariable = env.DOCKER_HOSTNAMES;
const portVariable = env.DOCKER_PORTS;
if (hostVariable === undefined || portVariable === undefined) {
return [{ host: "socket", instance: new Docker() }];
}
const hostnames = hostVariable.split(",");
const ports = portVariable.split(",");
if (hostnames.length !== ports.length) {
throw new Error("The number of hosts and ports must match");
}
return hostnames.map((host, i) => {
// Check above ensures that ports[i] is not undefined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const port = ports[i]!;
return {
host: `${host}:${port}`,
instance: new Docker({
host,
port: parseInt(port, 10),
}),
};
});
}
public static findInstance(host: string): DockerInstance | undefined {
return this.instances?.find((instance) => instance.host === host);
}
public static getInstances(): DockerInstance[] {
if (this.instances) {
return this.instances;
}
this.instances = new DockerSingleton().createInstances();
return this.instances;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.16.0"
"@mantine/form": "^7.16.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -37,7 +37,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"proxmox-api": "1.1.1",
"undici": "7.2.3",
"undici": "7.3.0",
"xml2js": "^0.6.2"
},
"devDependencies": {

View File

@@ -32,10 +32,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"@mantine/core": "^7.16.1",
"@tabler/icons-react": "^3.29.0",
"dayjs": "^1.11.13",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},

View File

@@ -24,8 +24,8 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/core": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"react": "19.0.0"
},
"devDependencies": {

View File

@@ -24,8 +24,8 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.16.0",
"@tabler/icons-react": "^3.28.1"
"@mantine/notifications": "^7.16.1",
"@tabler/icons-react": "^3.29.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -37,10 +37,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/core": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"adm-zip": "0.5.16",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",

View File

@@ -15,7 +15,7 @@ export const mapApp = (
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> => {
): InferInsertModel<typeof items> | null => {
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
const shapeForSize = app.shape[boardSize];
@@ -25,7 +25,8 @@ export const mapApp = (
const sectionId = sectionMap.get(app.area.properties.id)?.id;
if (!sectionId) {
throw new Error(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'`);
logger.warn(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'. Removing app`);
return null;
}
return {
@@ -69,9 +70,10 @@ export const mapWidget = (
const sectionId = sectionMap.get(widget.area.properties.id)?.id;
if (!sectionId) {
throw new Error(
`Failed to find section for widget widgetId='${widget.id}' sectionId='${widget.area.properties.id}'`,
logger.warn(
`Failed to find section for widget widgetId='${widget.id}' sectionId='${widget.area.properties.id}'. Removing widget`,
);
return null;
}
return {

View File

@@ -10,5 +10,5 @@ export const prepareItems = (
) =>
widgets
.map((widget) => mapWidget(widget, boardSize, appsMap, sectionMap))
.filter((widget) => widget !== null)
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)));
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)))
.filter((widget) => widget !== null);

View File

@@ -22,6 +22,7 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@extractus/feed-extractor": "7.1.3",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",

View File

@@ -0,0 +1,127 @@
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
import { extract } from "@extractus/feed-extractor";
import dayjs from "dayjs";
import { z } from "zod";
import type { Modify } from "@homarr/common/types";
import { logger } from "@homarr/log";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
queryKey: "rssFeedList",
widgetKind: "rssFeed",
async requestAsync(input: { url: string; count: number }) {
const result = (await extract(input.url, {
getExtraEntryFields: (feedEntry) => {
const media = attemptGetImageFromEntry(input.url, feedEntry);
if (!media) {
return {};
}
return {
enclosure: media,
};
},
})) as ExtendedFeedData;
return {
...result,
entries: result.entries?.slice(0, input.count) ?? [],
};
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
const media = getFirstMediaProperty(entry);
if (media !== null) {
return media;
}
return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry));
};
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
const result = regex.exec(content);
if (result == null) {
return null;
}
console.debug(
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
);
return result[0];
};
const mediaProperties = [
{
path: ["enclosure", "@_url"],
},
{
path: ["media:content", "@_url"],
},
];
/**
* The RSS and Atom standards are poorly adhered to in most of the web.
* We want to show pretty background images on the posts and therefore need to extract
* the enclosure (aka. media images). This function uses the dynamic properties defined above
* to search through the possible paths and detect valid image URLs.
* @param feedObject The object to scan for.
* @returns the value of the first path that is found within the object
*/
const getFirstMediaProperty = (feedObject: object) => {
for (const mediaProperty of mediaProperties) {
let propertyIndex = 0;
let objectAtPath: object = feedObject;
while (propertyIndex < mediaProperty.path.length) {
const key = mediaProperty.path[propertyIndex];
if (key === undefined) {
break;
}
const propertyEntries = Object.entries(objectAtPath);
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
if (!propertyEntry) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [_, propertyEntryValue] = propertyEntry;
objectAtPath = propertyEntryValue as object;
propertyIndex++;
}
const validationResult = z.string().url().safeParse(objectAtPath);
if (!validationResult.success) {
continue;
}
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
return validationResult.data;
}
return null;
};
/**
* We extend the feed with custom properties.
* This interface adds properties on top of the default ones.
*/
interface ExtendedFeedEntry extends FeedEntry {
enclosure?: string;
}
/**
* We extend the feed with custom properties.
* This interface omits the default entries with our custom definition.
*/
type ExtendedFeedData = Modify<
FeedData,
{
entries?: ExtendedFeedEntry[];
}
>;
export interface RssFeed {
feedUrl: string;
feed: ExtendedFeedData;
}

View File

@@ -32,12 +32,12 @@
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/spotlight": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"jotai": "^2.11.0",
"next": "15.1.5",
"@mantine/core": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"@mantine/spotlight": "^7.16.1",
"@tabler/icons-react": "^3.29.0",
"jotai": "^2.11.1",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"use-deep-compare-effect": "^1.8.1"

View File

@@ -32,7 +32,7 @@
"dayjs": "^1.11.13",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.5",
"next": "15.1.6",
"next-intl": "3.26.3",
"react": "19.0.0",
"react-dom": "19.0.0"

View File

@@ -733,6 +733,14 @@
"password": {
"label": "密码",
"newLabel": "新密码"
},
"tokenId": {
"label": "密鑰 ID",
"newLabel": "新密鑰 ID"
},
"realm": {
"label": "領域",
"newLabel": "新領域"
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "复制 URL"
},
"open": {
"label": ""
}
}
},
@@ -897,7 +908,7 @@
"passwordRequirements": "密码不符合要求",
"boardAlreadyExists": "具有此名称的面板已存在",
"invalidFileType": "无效的文件类型,例如 {expected}",
"invalidFileName": "",
"invalidFileName": "無效的檔案名稱",
"fileTooLarge": "文件太大,最大大小为 {maxSize}",
"invalidConfiguration": "无效配置",
"groupNameTaken": "用户组名称已存在"
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "显示文件系统信息"
},
"defaultTab": {
"label": "預設頁面"
},
"sectionIndicatorRequirement": {
"label": "部分指示需求"
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "获取健康状态失败"
},
"cluster": {
"summary": {
"cpu": "處理器",
"memory": "記憶體"
},
"resource": {
"node": {
"name": "節點"
},
"qemu": {
"name": "虛擬機"
},
"lxc": {
"name": "LXCs"
},
"storage": {
"name": "儲存裝置"
}
},
"popover": {
"rightSection": {
"node": "節點",
"vmId": "虛擬機 ID",
"plugin": "插件"
},
"detail": {
"cpu": "核心",
"memory": "記憶體",
"storage": "儲存裝置",
"uptime": "運行時間",
"haState": "HA 状态",
"storageType": {
"local": "本地存储",
"shared": "共享存储"
}
}
},
"table": {
"header": {
"name": "名称",
"cpu": "处理器",
"memory": "内存",
"node": "节点"
}
}
}
},
"common": {
@@ -2102,7 +2165,7 @@
"docker": "Docker",
"logs": "日志",
"api": "API",
"certificates": "",
"certificates": "证书",
"tasks": "任务"
}
},
@@ -2710,7 +2773,7 @@
"label": "日志"
},
"certificates": {
"label": ""
"label": "证书"
}
},
"settings": {
@@ -3110,39 +3173,39 @@
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"title": "可信证书",
"description": "Homarr 用于从集成请求数据。",
"noResults": {
"title": ""
"title": "还没有证书"
},
"expires": ""
"expires": "到期时间 {when}"
}
},
"action": {
"create": {
"label": "",
"label": "添加证书",
"notification": {
"success": {
"title": "",
"message": ""
"title": "已添加证书",
"message": "已成功添加证书"
},
"error": {
"title": "",
"message": ""
"title": "添加证书失败",
"message": "无法添加证书"
}
}
},
"remove": {
"label": "",
"confirm": "",
"label": "删除证书",
"confirm": "您确定要删除证书吗?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "证书已删除",
"message": "证书删除成功"
},
"error": {
"title": "",
"message": ""
"title": "证书未删除",
"message": "无法删除证书"
}
}
}

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Heslo",
"newLabel": "Nové heslo"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "Zkopírovat URL"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Zobrazit informace o souborovém systému"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

File diff suppressed because it is too large Load Diff

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Passwort",
"newLabel": "Neues Passwort"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "URL in Zwischenablage kopieren"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Dateisystem Info anzeigen"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Fehler beim Abrufen des Gesundheitsstatus"
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Κωδικός",
"newLabel": "Νέος κωδικός"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Εμφάνιση Πληροφοριών Συστήματος Αρχείων"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -785,6 +785,9 @@
},
"copy": {
"label": "Copy URL"
},
"open": {
"label": "Open media"
}
}
},

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Contraseña",
"newLabel": "Nueva contraseña"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Mostrar información del sistema de archivos"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "",
"newLabel": ""
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Mot de passe",
"newLabel": "Nouveau mot de passe"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "Copier l'URL"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Afficher les infos sur le système de fichiers"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "סיסמה",
"newLabel": "סיסמה חדשה"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "העתק קישור"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "הצג מידע על מערכת הקבצים"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "נכשל בעדכון סטטוס בריאות המערכת"
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Lozinka",
"newLabel": ""
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Jelszó",
"newLabel": "Új jelszó"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "URL másolása"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Fájlrendszer-információk megjelenítése"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "",
"newLabel": "Nuova password"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Mostra Informazioni Filesystem"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "パスワード",
"newLabel": "新しいパスワード"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "ファイルシステム情報を表示"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "비밀번호",
"newLabel": ""
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Slaptažodis",
"newLabel": "Naujas slaptažodis"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Parole",
"newLabel": "Jauna parole"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Rādīt failu sistēmas informāciju"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -213,10 +213,10 @@
"changeDefaultSearchEngine": {
"notification": {
"success": {
"message": ""
"message": "Standaard zoekmachine succesvol gewijzigd"
},
"error": {
"message": ""
"message": "Standaard zoekmachine kan niet worden gewijzigd"
}
}
},
@@ -499,7 +499,7 @@
}
},
"app": {
"search": "",
"search": "Vind een app",
"page": {
"list": {
"title": "Apps",
@@ -625,8 +625,8 @@
"label": "URL"
},
"attemptSearchEngineCreation": {
"label": "",
"description": ""
"label": "Zoekmachine aanmaken",
"description": "Integratie \"{kind}\" kan worden gebruikt met de zoekmachines. Controleer dit om automatisch de zoekmachine te configureren."
}
},
"action": {
@@ -733,6 +733,14 @@
"password": {
"label": "Wachtwoord",
"newLabel": "Nieuw wachtwoord"
},
"tokenId": {
"label": "Token ID",
"newLabel": "Nieuwe token ID"
},
"realm": {
"label": "Realm",
"newLabel": "Nieuwe Realm"
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "URL kopiëren"
},
"open": {
"label": "Media openen"
}
}
},
@@ -897,7 +908,7 @@
"passwordRequirements": "Wachtwoord voldoet niet aan de eisen",
"boardAlreadyExists": "Er bestaat al een bord met deze naam",
"invalidFileType": "Ongeldig bestandstype, verwachtte {expected}",
"invalidFileName": "",
"invalidFileName": "Ongeldige bestandsnaam",
"fileTooLarge": "Bestand is te groot, maximale grootte is {maxSize}",
"invalidConfiguration": "Ongeldige configuratie",
"groupNameTaken": "Groepsnaam al in gebruik"
@@ -967,7 +978,7 @@
},
"create": {
"title": "Kies een item om toe te voegen",
"search": "",
"search": "Items filteren",
"addToBoard": "Toevoegen aan bord"
},
"moveResize": {
@@ -1094,7 +1105,7 @@
"dnsQueriesToday": "Aanvragen vandaag",
"domainsBeingBlocked": "Domeinen op blokkadelijst"
},
"domainsTooltip": ""
"domainsTooltip": "Vanwege meerdere integraties kan Homarr het exacte aantal geblokkeerde domeinen niet berekenen"
},
"dnsHoleControls": {
"name": "DNS-hole bedieningen",
@@ -1170,22 +1181,22 @@
}
},
"minecraftServerStatus": {
"name": "",
"description": "",
"name": "Minecraft server status",
"description": "Geeft de status van een Minecraft server weer",
"option": {
"title": {
"label": ""
"label": "Titel"
},
"domain": {
"label": ""
"label": "Serveradres"
},
"isBedrockServer": {
"label": ""
"label": "Bedrock server"
}
},
"status": {
"online": "",
"offline": ""
"online": "Online",
"offline": "Offline"
}
},
"notebook": {
@@ -1283,7 +1294,7 @@
},
"error": {
"noUrl": "Geen iFrame URL opgegeven",
"unsupportedProtocol": "",
"unsupportedProtocol": "De opgegeven URL gebruikt een niet-ondersteund protocol. Gebruik een van ({supportedProtocols})",
"noBrowerSupport": "Je browser ondersteunt geen iframes. Update je browser."
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Bestandssysteem info weergeven"
},
"defaultTab": {
"label": "Standaard tab"
},
"sectionIndicatorRequirement": {
"label": "Sectie indicator vereiste"
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Gezondheidsstatus ophalen mislukt"
},
"cluster": {
"summary": {
"cpu": "CPU",
"memory": "RAM"
},
"resource": {
"node": {
"name": "Nodes"
},
"qemu": {
"name": "VM's"
},
"lxc": {
"name": "LXC's"
},
"storage": {
"name": "Opslagruimte"
}
},
"popover": {
"rightSection": {
"node": "Node",
"vmId": "VM ID",
"plugin": "Plugin"
},
"detail": {
"cpu": "Cores",
"memory": "Geheugen",
"storage": "Opslagruimte",
"uptime": "Uptime",
"haState": "HA status",
"storageType": {
"local": "Lokale opslag",
"shared": "Gedeelde opslag"
}
}
},
"table": {
"header": {
"name": "Naam",
"cpu": "CPU",
"memory": "RAM",
"node": "Node"
}
}
}
},
"common": {
@@ -1497,9 +1560,9 @@
"description": "De huidige streams op je mediaservers weergeven",
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
"user": "Gebruiker",
"name": "Naam",
"id": "Id"
}
},
"downloads": {
@@ -1776,16 +1839,16 @@
"board": {
"action": {
"duplicate": {
"title": "",
"message": "",
"title": "Bord dupliceren",
"message": "Dit zal het bord {name} dupliceren met al zijn inhoud. Als widgets refereren aan integraties, die je niet mag gebruiken, zullen ze worden verwijderd.",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Bord gedupliceerd",
"message": "Het bord is succesvol gedupliceerd"
},
"error": {
"title": "",
"message": ""
"title": "Kan board niet dupliceren",
"message": "Het bord kon niet worden gedupliceerd"
}
}
},
@@ -2102,7 +2165,7 @@
"docker": "Docker",
"logs": "Logs",
"api": "API",
"certificates": "",
"certificates": "Certificaten",
"tasks": "Taken"
}
},
@@ -2156,14 +2219,14 @@
}
},
"setMobileHomeBoard": {
"label": "",
"label": "Instellen als je mobiele bord",
"badge": {
"label": "",
"tooltip": ""
"label": "Mobiel",
"tooltip": "Dit bord zal getoond worden als je mobiele bord"
}
},
"duplicate": {
"label": ""
"label": "Bord dupliceren"
},
"delete": {
"label": "Permanent verwijderen",
@@ -2199,13 +2262,13 @@
"item": {
"language": "Taal & regio",
"board": {
"title": "",
"title": "Home bord",
"type": {
"general": "",
"mobile": ""
"general": "Algemeen",
"mobile": "Mobiel"
}
},
"defaultSearchEngine": "",
"defaultSearchEngine": "Standaard zoekmachine",
"firstDayOfWeek": "Eerste dag van de week",
"accessibility": "Toegankelijkheid"
}
@@ -2364,15 +2427,15 @@
"title": "Borden",
"homeBoard": {
"label": "Globaal home-bord",
"mobileLabel": "",
"mobileLabel": "Globaal mobiel bord",
"description": "Alleen openbare borden zijn beschikbaar voor selectie"
}
},
"search": {
"title": "",
"title": "Zoeken",
"defaultSearchEngine": {
"label": "",
"description": ""
"label": "Globale standaard zoekmachine",
"description": "Integratie zoekmachines kunnen hier niet geselecteerd worden"
}
},
"appearance": {
@@ -2403,7 +2466,7 @@
},
"job": {
"minecraftServerStatus": {
"label": ""
"label": "Minecraft server status"
},
"iconsUpdater": {
"label": "Icoon updater"
@@ -2601,19 +2664,19 @@
}
},
"addToHomarr": {
"label": "",
"label": "Toevoegen aan Homarr",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Toegevoegd aan Homarr",
"message": "Geselecteerde apps zijn toegevoegd aan Homarr"
},
"error": {
"title": "",
"message": ""
"title": "Kon niet toevoegen aan Homarr",
"message": "Geselecteerde apps konden niet worden toegevoegd aan Homarr"
}
},
"modal": {
"title": ""
"title": "Docker container('s) toevoegen aan Homarr"
}
}
},
@@ -2710,7 +2773,7 @@
"label": "Logs"
},
"certificates": {
"label": ""
"label": "Certificaten"
}
},
"settings": {
@@ -2758,7 +2821,7 @@
"label": "Instellen als home-bord"
},
"mobileBoard": {
"label": ""
"label": "Instellen als mobiel bord"
},
"settings": {
"label": "Instellingen openen"
@@ -2826,9 +2889,9 @@
}
},
"media": {
"requestMovie": "",
"requestSeries": "",
"openIn": ""
"requestMovie": "Film aanvragen",
"requestSeries": "Series aanvragen",
"openIn": "Openen in {kind}"
},
"external": {
"help": "Gebruik een externe zoekmachine",
@@ -2897,20 +2960,20 @@
"home": {
"group": {
"search": {
"title": "",
"title": "Zoeken",
"option": {
"other": {
"label": ""
"label": "Zoeken met andere zoekmachine"
},
"no-default": {
"label": "",
"description": ""
"label": "Geen standaard zoekmachine",
"description": "Stel een standaard zoekmachine in in voorkeuren"
},
"search": {
"label": ""
"label": "Zoeken naar \"{query}\" met {name}"
},
"from-integration": {
"description": ""
"description": "Typ om te zoeken"
}
}
},
@@ -3092,15 +3155,15 @@
"media": {
"request": {
"modal": {
"title": "",
"title": "\"{name}\" aanvragen",
"table": {
"header": {
"season": "",
"episodes": ""
"season": "Seizoen",
"episodes": "Afleveringen"
}
},
"button": {
"send": ""
"send": "Aanvraag versturen"
}
}
}
@@ -3110,39 +3173,39 @@
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"title": "Vertrouwde certificaten",
"description": "Gebruikt door Homarr om gegevens op te vragen van integraties.",
"noResults": {
"title": ""
"title": "Er zijn nog geen certificaten"
},
"expires": ""
"expires": "Verloopt {when}"
}
},
"action": {
"create": {
"label": "",
"label": "Certificaat toevoegen",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Certificaat toegevoegd",
"message": "Het certificaat is succesvol toegevoegd"
},
"error": {
"title": "",
"message": ""
"title": "Certificaat toevoegen mislukt",
"message": "Het certificaat kon niet worden toegevoegd"
}
}
},
"remove": {
"label": "",
"confirm": "",
"label": "Certificaten verwijderen",
"confirm": "Weet je zeker dat je het certificaat wilt verwijderen?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Certificaat verwijderd",
"message": "Het certificaat is succesvol verwijderd"
},
"error": {
"title": "",
"message": ""
"title": "Certificaat niet verwijderd",
"message": "Certificaat kon niet worden verwijderd"
}
}
}

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Passord",
"newLabel": "Nytt passord"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Vis filsysteminfo"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Hasło",
"newLabel": "Nowe hasło"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "Kopiuj adres URL"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Pokaż informacje o systemie plików"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Nie udało się pobrać stanu zdrowia"
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -2,123 +2,123 @@
"init": {
"step": {
"start": {
"title": "",
"subtitle": "",
"description": "",
"title": "Bem-vindo ao Homarr",
"subtitle": "Vamos começar configurando a sua instância Homarr.",
"description": "Para começar, por favor selecione como você deseja configurar sua instância Homarr.",
"action": {
"scratch": "",
"importOldmarr": ""
"scratch": "Começar do zero",
"importOldmarr": "Importe de uma instância Homarr anterior a 1,0"
}
},
"import": {
"title": "",
"subtitle": "",
"title": "Importar dados",
"subtitle": "Você pode importar dados de uma instância existente do Homarr.",
"dropzone": {
"title": "",
"description": ""
"title": "Arraste o arquivo zip aqui ou clique para procurar",
"description": "O arquivo zip enviado será processado e você poderá selecionar o que deseja importar"
},
"fileInfo": {
"action": {
"change": ""
"change": "Alterar arquivo"
}
},
"importSettings": {
"title": "",
"description": ""
"title": "Importar configurações",
"description": "Configure o comportamento da importação"
},
"boardSelection": {
"title": "",
"description": "",
"title": "{count} quadros encontrados",
"description": "Escolha todos os quadros com o tamanho que você deseja importar",
"action": {
"selectAll": "",
"unselectAll": ""
"selectAll": "Selecionar todos",
"unselectAll": "Desmarcar Todos"
}
},
"summary": {
"title": "",
"description": "",
"title": "Resumo da importação",
"description": "No resumo abaixo, você pode ver o que será importado",
"action": {
"import": ""
"import": "Confirmar a importação e continuar"
},
"entities": {
"apps": "Aplicativos",
"boards": "Placas",
"integrations": "",
"credentialUsers": ""
"integrations": "Integrações",
"credentialUsers": "Usuários credenciados"
}
},
"tokenModal": {
"title": "",
"title": "Insira o token de importação",
"field": {
"token": {
"label": "",
"description": ""
"label": "Token",
"description": "Insira o token de importação mostrado em sua instância homarr anterior"
}
},
"notification": {
"error": {
"title": "",
"message": ""
"title": "Token inválido",
"message": "O token que você inseriu é inválido"
}
}
}
},
"user": {
"title": "",
"subtitle": "",
"title": "Usuário Administrador",
"subtitle": "Especifique as credenciais para seu usuário administrador.",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Usuário criado",
"message": "O usuário foi criado com sucesso"
},
"error": {
"title": ""
"title": "Falha ao criar usuário"
}
}
},
"group": {
"title": "",
"subtitle": "",
"title": "Grupo externo",
"subtitle": "Especifique o grupo que deve ser usado para usuários externos.",
"form": {
"name": {
"label": "",
"description": ""
"label": "Nome do grupo",
"description": "O nome tem que coincidir com o grupo administrador do provedor externo"
}
}
},
"settings": {
"title": "Configurações",
"subtitle": ""
"subtitle": "Definir as configurações do servidor."
},
"finish": {
"title": "",
"subtitle": "",
"description": "",
"title": "Concluir configuração",
"subtitle": "Você está pronto para começar!",
"description": "Você concluiu com sucesso o processo de configuração. Agora você pode começar a usar o Homarr. Selecione sua próxima ação:",
"action": {
"goToBoard": "",
"createBoard": "",
"inviteUser": "",
"docs": ""
"goToBoard": "Ir para o quadro {name}",
"createBoard": "Crie seu primeiro quadro",
"inviteUser": "Convidar outros usuários",
"docs": "Ler a documentação"
}
}
},
"backToStart": ""
"backToStart": "Voltar ao início"
},
"user": {
"title": "Usuários",
"name": "Usuário",
"page": {
"login": {
"title": "",
"subtitle": ""
"title": "Acesse a sua conta",
"subtitle": "Bem-vindo(a) de volta! Por favor, insira suas credenciais"
},
"invite": {
"title": "",
"subtitle": "",
"description": ""
"title": "Junte-se ao Homarr",
"subtitle": "Bem-vindo ao Homarr! Por favor, crie sua conta",
"description": "Você foi convidado(a) por {username}"
},
"init": {
"title": "",
"title": "Nova instalação do Homarr",
"subtitle": ""
}
},
@@ -733,6 +733,14 @@
"password": {
"label": "Senha",
"newLabel": "Nova senha"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Mostrar informações do sistema de arquivos"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Parola",
"newLabel": "Parolă nouă"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Afișare informații despre sistemul de fișiere"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -213,10 +213,10 @@
"changeDefaultSearchEngine": {
"notification": {
"success": {
"message": ""
"message": "Поисковая система по умолчанию успешно изменена"
},
"error": {
"message": ""
"message": "Не удалось изменить поисковую систему по умолчанию"
}
}
},
@@ -499,7 +499,7 @@
}
},
"app": {
"search": "",
"search": "Поиск приложений",
"page": {
"list": {
"title": "Приложения",
@@ -625,8 +625,8 @@
"label": "URL-адрес"
},
"attemptSearchEngineCreation": {
"label": "",
"description": ""
"label": "Создать поисковую систему",
"description": "Интеграцию \"{kind}\" можно использовать с поисковыми системами. Включите эту опцию для автоматической настройки поисковой системы."
}
},
"action": {
@@ -733,6 +733,14 @@
"password": {
"label": "Пароль",
"newLabel": "Новый пароль"
},
"tokenId": {
"label": "ID токена",
"newLabel": "Новый ID токена"
},
"realm": {
"label": "Область",
"newLabel": "Новая область"
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "Копировать URL"
},
"open": {
"label": ""
}
}
},
@@ -897,7 +908,7 @@
"passwordRequirements": "Пароль не соответствует требованиям",
"boardAlreadyExists": "Панель с таким именем уже существует",
"invalidFileType": "Неверный тип файла, ожидается {expected}",
"invalidFileName": "",
"invalidFileName": "Недопустимое имя файла",
"fileTooLarge": "Файл слишком большой, максимальный размер {maxSize}",
"invalidConfiguration": "Неверная конфигурация",
"groupNameTaken": "Имя группы уже занято"
@@ -967,7 +978,7 @@
},
"create": {
"title": "Выберите тип элемента",
"search": "",
"search": "Фильтр элементов",
"addToBoard": "Добавить на панель"
},
"moveResize": {
@@ -1094,7 +1105,7 @@
"dnsQueriesToday": "DNS-запросов сегодня",
"domainsBeingBlocked": "Доменов в списке блокировки"
},
"domainsTooltip": ""
"domainsTooltip": "Из-за наличия нескольких интеграций Homarr не может рассчитать точное количество заблокированных доменов"
},
"dnsHoleControls": {
"name": "Управление DNS-фильтром",
@@ -1170,22 +1181,22 @@
}
},
"minecraftServerStatus": {
"name": "",
"description": "",
"name": "Статус сервера Minecraft",
"description": "Отображает текущий статус и информацию о сервере Minecraft",
"option": {
"title": {
"label": ""
"label": "Заголовок"
},
"domain": {
"label": ""
"label": "Домен сервера"
},
"isBedrockServer": {
"label": ""
"label": "Сервер Bedrock Edition"
}
},
"status": {
"online": "",
"offline": ""
"online": "В сети",
"offline": "Не в сети"
}
},
"notebook": {
@@ -1283,7 +1294,7 @@
},
"error": {
"noUrl": "URL для iFrame не указан",
"unsupportedProtocol": "",
"unsupportedProtocol": "Указанный URL использует неподдерживаемый протокол. Пожалуйста, используйте один из ({supportedProtocols})",
"noBrowerSupport": "Ваш браузер не поддерживает iframes. Пожалуйста, обновите браузер."
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Показать информацию о файловой системе"
},
"defaultTab": {
"label": "Вкладка по умолчанию"
},
"sectionIndicatorRequirement": {
"label": "Пороговое значение индикатора раздела"
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Не удалось получить данные о состоянии системы"
},
"cluster": {
"summary": {
"cpu": "ЦП",
"memory": "Память"
},
"resource": {
"node": {
"name": "Узлы"
},
"qemu": {
"name": "Виртуальные машины"
},
"lxc": {
"name": "Контейнеры LXC"
},
"storage": {
"name": "Хранилище"
}
},
"popover": {
"rightSection": {
"node": "Узел",
"vmId": "ID виртуальной машины",
"plugin": "Плагин"
},
"detail": {
"cpu": "Ядер",
"memory": "Память",
"storage": "Хранилище",
"uptime": "Время работы",
"haState": "Состояние HA",
"storageType": {
"local": "Локальное",
"shared": "Общее"
}
}
},
"table": {
"header": {
"name": "Название",
"cpu": "ЦП",
"memory": "Память",
"node": "Узел"
}
}
}
},
"common": {
@@ -1497,9 +1560,9 @@
"description": "Отображает текущие сеансы воспроизведения на медиасерверах",
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
"user": "Пользователь",
"name": "Название",
"id": "Id"
}
},
"downloads": {
@@ -1776,16 +1839,16 @@
"board": {
"action": {
"duplicate": {
"title": "",
"message": "",
"title": "Дублировать панель",
"message": "Это действие создаст копию панели {name} со всем её содержимым. Если виджеты используют интеграции, к которым у вас нет доступа, они будут удалены.",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Панель продублирована",
"message": "Панель успешно продублирована"
},
"error": {
"title": "",
"message": ""
"title": "Ошибка дублирования",
"message": "Не удалось продублировать панель"
}
}
},
@@ -2102,7 +2165,7 @@
"docker": "Docker",
"logs": "Логи",
"api": "API",
"certificates": "",
"certificates": "Сертификаты",
"tasks": "Задачи"
}
},
@@ -2156,14 +2219,14 @@
}
},
"setMobileHomeBoard": {
"label": "",
"label": "Сделать домашней панелью для мобильных устройств",
"badge": {
"label": "",
"tooltip": ""
"label": "Мобильная",
"tooltip": "Эта панель будет показываться как ваша домашняя панель на мобильных устройствах"
}
},
"duplicate": {
"label": ""
"label": "Дублировать"
},
"delete": {
"label": "Удалить навсегда",
@@ -2199,13 +2262,13 @@
"item": {
"language": "Язык и регион",
"board": {
"title": "",
"title": "Домашняя панель",
"type": {
"general": "",
"mobile": ""
"general": "Основная",
"mobile": "Мобильная"
}
},
"defaultSearchEngine": "",
"defaultSearchEngine": "Поисковая система по умолчанию",
"firstDayOfWeek": "Первый день недели",
"accessibility": "Специальные возможности"
}
@@ -2363,16 +2426,16 @@
"board": {
"title": "Панели",
"homeBoard": {
"label": "Общая домашняя панель",
"mobileLabel": "",
"label": "Глобальная домашняя панель",
"mobileLabel": "Глобальная мобильная панель",
"description": "Для выбора доступны только публичные панели"
}
},
"search": {
"title": "",
"title": "Поиск",
"defaultSearchEngine": {
"label": "",
"description": ""
"label": "Глобальная поисковая система",
"description": "Поисковые системы из интеграций нельзя выбрать здесь"
}
},
"appearance": {
@@ -2403,7 +2466,7 @@
},
"job": {
"minecraftServerStatus": {
"label": ""
"label": "Статус сервера Minecraft"
},
"iconsUpdater": {
"label": "Обновление иконок"
@@ -2601,19 +2664,19 @@
}
},
"addToHomarr": {
"label": "",
"label": "Добавить в Homarr",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Приложение добавлено",
"message": "Приложение успешно добавлено в Homarr"
},
"error": {
"title": "",
"message": ""
"title": "Не удалось добавить",
"message": "Не удалось добавить приложение в Homarr"
}
},
"modal": {
"title": ""
"title": "Добавить контейнер(ы) Docker в Homarr"
}
}
},
@@ -2710,7 +2773,7 @@
"label": "Логи"
},
"certificates": {
"label": ""
"label": "Сертификаты"
}
},
"settings": {
@@ -2758,7 +2821,7 @@
"label": "Сделать домашней панелью"
},
"mobileBoard": {
"label": ""
"label": "Сделать домашней панелью для мобильных устройств"
},
"settings": {
"label": "Открыть настройки"
@@ -2826,9 +2889,9 @@
}
},
"media": {
"requestMovie": "",
"requestSeries": "",
"openIn": ""
"requestMovie": "Запросить фильм",
"requestSeries": "Запросить сериал",
"openIn": "Открыть в {kind}"
},
"external": {
"help": "Использовать внешнюю поисковую систему",
@@ -2897,20 +2960,20 @@
"home": {
"group": {
"search": {
"title": "",
"title": "Поиск",
"option": {
"other": {
"label": ""
"label": "Искать в другой поисковой системе"
},
"no-default": {
"label": "",
"description": ""
"label": "Нет поисковой системы по умолчанию",
"description": "Установите поисковую систему по умолчанию в настройках"
},
"search": {
"label": ""
"label": "Искать \"{query}\" через {name}"
},
"from-integration": {
"description": ""
"description": "Начните вводить для поиска"
}
}
},
@@ -3092,15 +3155,15 @@
"media": {
"request": {
"modal": {
"title": "",
"title": "Запрос \"{name}\"",
"table": {
"header": {
"season": "",
"episodes": ""
"season": "Сезон",
"episodes": "Серии"
}
},
"button": {
"send": ""
"send": "Отправить запрос"
}
}
}
@@ -3110,39 +3173,39 @@
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"title": "Доверенные сертификаты",
"description": "Используются Homarr для запроса данных из интеграций.",
"noResults": {
"title": ""
"title": "Доверенные сертификаты отсутствуют"
},
"expires": ""
"expires": "Истекает {when}"
}
},
"action": {
"create": {
"label": "",
"label": "Создать сертификат",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Сертификат создан",
"message": "Сертификат успешно создан"
},
"error": {
"title": "",
"message": ""
"title": "Не удалось создать сертификат",
"message": "Произошла ошибка при создании сертификата"
}
}
},
"remove": {
"label": "",
"confirm": "",
"label": "Удалить сертификат",
"confirm": "Вы уверены, что хотите удалить этот сертификат?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Сертификат удален",
"message": "Сертификат успешно удален"
},
"error": {
"title": "",
"message": ""
"title": "Не удалось удалить сертификат",
"message": "Произошла ошибка при удалении сертификата"
}
}
}

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Heslo",
"newLabel": "Nové heslo"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "Skopírovať URL"
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Zobraziť informácie o súborovom systéme"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Nepodarilo sa načítať zdravotný stav"
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Geslo",
"newLabel": "Novo geslo"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Lösenord",
"newLabel": "Nytt lösenord"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Visa information om filsystemet"
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -712,7 +712,7 @@
"label": "Değer ayarlanmadı",
"tooltip": "Gizli anahtar gereklidir ve henüz ayarlanmadı"
},
"secureNotice": "Bu Gizli anahtar oluşturulduktan sonra geri alınamaz",
"secureNotice": "Entegrasyonu oluşturduktan sonra gizli anahtarı tekrar görüntüleyemezsiniz",
"reset": {
"title": "Gizli anahtarı sıfırla",
"message": "Gizli anahtarı sıfırlamak istediğinizden emin misiniz?"
@@ -733,6 +733,14 @@
"password": {
"label": "Şifre",
"newLabel": "Yeni Şifre"
},
"tokenId": {
"label": "Token Kimliği",
"newLabel": "Yeni Token Kimliği"
},
"realm": {
"label": "Realm",
"newLabel": "Yeni realm"
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "URL'yi kopyala"
},
"open": {
"label": ""
}
}
},
@@ -897,7 +908,7 @@
"passwordRequirements": "Parola gereksinimleri karşılamıyor",
"boardAlreadyExists": "Bu isimde bir panel zaten mevcut",
"invalidFileType": "Geçersiz dosya türü, {expected} bekleniyor",
"invalidFileName": "",
"invalidFileName": "Geçersiz dosya adı",
"fileTooLarge": "Dosya çok büyük, azami boyut {maxSize}",
"invalidConfiguration": "Geçersiz yapılandırma",
"groupNameTaken": "Grup adı zaten alınmış"
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "Dosya Sistemi Bilgilerini Göster"
},
"defaultTab": {
"label": "Varsayılan sekme"
},
"sectionIndicatorRequirement": {
"label": "Bölüm göstergesi gereksinimi"
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "Sağlık durumu alınamadı"
},
"cluster": {
"summary": {
"cpu": "İşlemci",
"memory": "BELLEK"
},
"resource": {
"node": {
"name": "Nodes"
},
"qemu": {
"name": "VM's"
},
"lxc": {
"name": "LXC'ler"
},
"storage": {
"name": "Depolama"
}
},
"popover": {
"rightSection": {
"node": "Node",
"vmId": "VM Kimliği",
"plugin": "Eklenti"
},
"detail": {
"cpu": "Çekirdekler",
"memory": "Hafıza",
"storage": "Depolama",
"uptime": "Çalışma Süresi",
"haState": "HA Durumu",
"storageType": {
"local": "Yerel depolama",
"shared": "Paylaşılan depolama"
}
}
},
"table": {
"header": {
"name": "İsim",
"cpu": "İşlemci",
"memory": "BELLEK",
"node": "Node"
}
}
}
},
"common": {
@@ -1673,7 +1736,7 @@
"main": "Medya İstatistikleri",
"approved": "Onaylanan",
"pending": "Onay bekleyen",
"processing": "İşleniyor",
"processing": "İşlenen",
"declined": "Reddedilen",
"available": "Mevcut",
"tv": "Dizi talepleri",
@@ -2039,9 +2102,9 @@
"error": {
"noBoard": {
"title": "Homarr'a Hoş Geldiniz",
"description": "Tüm uygulamalarınızı ve hizmetlerinizi parmaklarınızın ucuna getiren şık ve modern bir gösterge paneli.",
"description": "Tüm uygulamalarınızı ve hizmetlerinizi parmaklarınızın ucuna getiren şık ve modern kontrol paneli.",
"link": "İlk panelinizi oluşturun",
"notice": "Bu sayfanın kaybolmasını sağlamak için bir panel oluşturun ve bunu ana panel olarak ayarlayın"
"notice": "Bu sayfanın kaybolmasını sağlamak için bir panel oluşturun ve bunu ön tanımlı panel olarak atayın"
},
"notFound": {
"title": "Panel bulunamadı",
@@ -2102,7 +2165,7 @@
"docker": "Docker",
"logs": "Günlükler",
"api": "API",
"certificates": "",
"certificates": "Sertifikalar",
"tasks": "Görevler"
}
},
@@ -2710,7 +2773,7 @@
"label": "Günlükler"
},
"certificates": {
"label": ""
"label": "Sertifikalar"
}
},
"settings": {
@@ -3110,39 +3173,39 @@
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"title": "Güvenilen sertifikalar",
"description": "Homarr tarafından, Entegrasyonlardan veri çekmek için kullanılır.",
"noResults": {
"title": ""
"title": "Henüz sertifika yok"
},
"expires": ""
"expires": "{when} Sonra Süresi Doluyor"
}
},
"action": {
"create": {
"label": "",
"label": "Sertifika ekle",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Sertifika eklendi",
"message": "Sertifika başarıyla eklendi"
},
"error": {
"title": "",
"message": ""
"title": "Sertifika eklenemedi",
"message": "Sertifika eklenemedi"
}
}
},
"remove": {
"label": "",
"confirm": "",
"label": "Sertifikayı kaldır",
"confirm": "Sertifikayı kaldırmak istediğinizden emin misiniz?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Sertifika kaldırıldı",
"message": "Sertifika başarıyla kaldırıldı"
},
"error": {
"title": "",
"message": ""
"title": "Sertifika kaldırılmadı",
"message": "Sertifika kaldırılamadı"
}
}
}

View File

@@ -316,19 +316,19 @@
"item": {
"create": {
"label": "Створення додатків",
"description": ""
"description": "Дозволити учасникам створювати додатки"
},
"use-all": {
"label": "",
"description": ""
"label": "Використовувати всі додатки",
"description": "Дозволити учасникам додавати будь-які додатки до своїх дошок"
},
"modify-all": {
"label": "",
"description": ""
"label": "Змінювати всі додатки",
"description": "Дозволити учасникам змінювати всі додатки"
},
"full-all": {
"label": "",
"description": ""
"label": "Повний доступ до додатка",
"description": "Дозволити учасникам керувати, використовувати і видаляти будь-які додатки"
}
}
},
@@ -336,84 +336,84 @@
"title": "Дошки",
"item": {
"create": {
"label": "",
"description": ""
"label": "Створити дошки",
"description": "Дозволити учасникам створювати дошки"
},
"view-all": {
"label": "",
"description": ""
"label": "Переглядати всі дошки",
"description": "Дозволити учасникам переглядати всі дошки"
},
"modify-all": {
"label": "",
"description": ""
"label": "Змінювати всі дошки",
"description": "Дозволити учасникам змінювати всі дошки (не включає контроль доступу та небезпечну зону)"
},
"full-all": {
"label": "",
"description": ""
"label": "Повний доступ до дошки",
"description": "Дозволити учасникам переглядати, змінювати та видаляти всі дошки (включаючи контроль доступу та небезпечну зону)"
}
}
},
"integration": {
"title": "",
"title": "Інтеграції",
"item": {
"create": {
"label": "",
"description": ""
"label": "Створювати інтеграції",
"description": "Дозволити учасникам створювати інтеграції"
},
"use-all": {
"label": "",
"description": ""
"label": "Використовувати всі інтеграції",
"description": "Дозволяє учасникам додавати будь-які інтеграції до своїх дошок"
},
"interact-all": {
"label": "",
"description": ""
"label": "Взаємодіяти з будь-якою інтеграцією",
"description": "Дозволити учасникам взаємодіяти з будь-якою інтеграцією"
},
"full-all": {
"label": "",
"description": ""
"label": "Повний доступ до інтеграції",
"description": "Дозволити учасникам керувати, використовувати та взаємодіяти з будь-якою інтеграцією"
}
}
},
"media": {
"title": "",
"title": "Медіа",
"item": {
"upload": {
"label": "",
"description": ""
"label": "Завантажити медіафайли",
"description": "Дозволити учасникам завантажувати медіафайли"
},
"view-all": {
"label": "",
"description": ""
"label": "Переглядати всі медіафайли",
"description": "Дозволити учасникам переглядати всі медіафайли"
},
"full-all": {
"label": "",
"description": ""
"label": "Повний доступ до медіафайлів",
"description": "Дозволити учасникам керувати та видаляти будь-які медіафайли"
}
}
},
"other": {
"title": "",
"title": "Інше",
"item": {
"view-logs": {
"label": "",
"description": ""
"label": "Переглядати логи",
"description": "Дозволити учасникам переглядати логи"
}
}
},
"search-engine": {
"title": "",
"title": "Пошукові системи",
"item": {
"create": {
"label": "",
"description": ""
"label": "Створення пошукових систем",
"description": "Дозволити учасникам створювати пошукові системи"
},
"modify-all": {
"label": "",
"description": ""
"label": "Змінити всі пошукові системи",
"description": "Дозволити учасникам змінювати всі пошукові системи"
},
"full-all": {
"label": "",
"description": ""
"label": "Повний доступ до пошукових систем",
"description": "Дозволити учасникам керувати та видаляти будь-які пошукові системи"
}
}
}
@@ -423,24 +423,24 @@
"external": ""
},
"reservedNotice": {
"message": ""
"message": "Ця група зарезервована для використання системою та обмежує деякі дії. <checkoutDocs></checkoutDocs>"
},
"action": {
"create": {
"label": "",
"label": "Нова група",
"notification": {
"success": {
"message": ""
"message": "Групу успішно створено"
},
"error": {
"message": ""
"message": "Не вдалося створити групу"
}
}
},
"transfer": {
"label": "",
"description": "",
"confirm": "",
"confirm": "Ви впевнені, що бажаєте передати право власності на групу {name} користувачу {username}?",
"notification": {
"success": {
"message": "Група {group} перенесена до {user}"
@@ -499,7 +499,7 @@
}
},
"app": {
"search": "",
"search": "Пошук додатків",
"page": {
"list": {
"title": "Додатки",
@@ -594,25 +594,25 @@
"notification": {
"success": {
"title": "Зміни застосовано",
"message": ""
"message": "Інтеграція успішно збережена"
},
"error": {
"title": "",
"message": ""
"title": "Не вдалося застосувати зміни",
"message": "Не вдалося зберегти інтеграцію"
}
}
},
"delete": {
"title": "",
"message": "",
"title": "Видалити інтеграцію",
"message": "Ви впевнені, що хочете видалити інтеграцію {name}?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Успішно видалено",
"message": "Інтеграцію успішно видалено"
},
"error": {
"title": "",
"message": ""
"title": "Не вдалося видалити",
"message": "Не вдалося видалити інтеграцію"
}
}
}
@@ -625,23 +625,23 @@
"label": ""
},
"attemptSearchEngineCreation": {
"label": "",
"label": "Створення пошукових систем",
"description": ""
}
},
"action": {
"create": ""
"create": "Нова інтеграція"
},
"testConnection": {
"action": {
"create": "",
"edit": ""
"create": "Перевірити підключення та створити",
"edit": "Перевірити підключення та зберегти"
},
"alertNotice": "",
"notification": {
"success": {
"title": "",
"message": ""
"title": "З'єднання встановлено",
"message": "З'єднання успішно встановлено"
},
"invalidUrl": {
"title": "Неправильна URL-адреса",
@@ -656,11 +656,11 @@
"message": ""
},
"commonError": {
"title": "",
"message": ""
"title": "Помилка підключення",
"message": "Не вдалося встановити з'єднання"
},
"badRequest": {
"title": "",
"title": "Невірний запит",
"message": ""
},
"unauthorized": {
@@ -668,28 +668,28 @@
"message": ""
},
"forbidden": {
"title": "",
"message": ""
"title": "Заборонено",
"message": "Ймовірно, відсутні дозволи"
},
"notFound": {
"title": "",
"title": "Не знайдено",
"message": ""
},
"internalServerError": {
"title": "",
"title": "Внутрішня помилка сервера",
"message": ""
},
"serviceUnavailable": {
"title": "",
"message": ""
"message": "Сервер зараз недоступний"
},
"connectionAborted": {
"title": "",
"message": ""
},
"domainNotFound": {
"title": "",
"message": ""
"title": "Домен не знайдено",
"message": "Домен не знайдено"
},
"connectionRefused": {
"title": "",
@@ -733,6 +733,14 @@
"password": {
"label": "Пароль",
"newLabel": "Новий пароль"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -744,7 +752,7 @@
},
"media": {
"plural": "",
"search": "",
"search": "Пошук медіафайлів",
"field": {
"name": "Ім’я",
"size": "Розмір",
@@ -753,7 +761,7 @@
"action": {
"upload": {
"label": "",
"file": "",
"file": "Виберіть файл",
"notification": {
"success": {
"message": ""
@@ -777,11 +785,14 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
"common": {
"beta": "",
"beta": "Бета-версія",
"error": "Помилка",
"action": {
"add": "Додати",
@@ -789,7 +800,7 @@
"backToOverview": "",
"create": "Створити",
"edit": "Редагувати",
"import": "",
"import": "Імпорт",
"insert": "Вставити",
"remove": "Видалити",
"save": "Зберегти",
@@ -798,13 +809,13 @@
"delete": "Видалити",
"discard": "",
"confirm": "Підтвердити",
"continue": "",
"continue": "Продовжити",
"previous": "Попередній",
"next": "Далі",
"checkoutDocs": "",
"checkLogs": "",
"checkLogs": "Щоб дізнатися більше, перевірте логи",
"tryAgain": "Повторіть спробу",
"loading": ""
"loading": "Завантаження"
},
"here": "",
"iconPicker": {
@@ -813,29 +824,29 @@
},
"colorScheme": {
"options": {
"light": "",
"dark": ""
"light": "Світла",
"dark": "Темна"
}
},
"information": {
"min": "",
"max": "",
"days": "",
"hours": "",
"minutes": ""
"days": "Днів",
"hours": "Годин",
"minutes": "Хвилин"
},
"notification": {
"create": {
"success": "",
"error": ""
"success": "Успішно створено",
"error": "Не вдалося створити"
},
"delete": {
"success": "",
"error": ""
"success": "Успішно видалено",
"error": "Не вдалося видалити"
},
"update": {
"success": "",
"error": ""
"success": "Зміни успішно застосовано",
"error": "Не вдалося застосувати зміни"
},
"transfer": {
"success": "",
@@ -847,7 +858,7 @@
},
"multiText": {
"placeholder": "",
"addLabel": ""
"addLabel": "Додати {value}"
},
"select": {
"placeholder": "",
@@ -857,14 +868,14 @@
},
"userAvatar": {
"menu": {
"switchToDarkMode": "",
"switchToLightMode": "",
"management": "",
"switchToDarkMode": "Перемикнути на темну тему",
"switchToLightMode": "Перемикнути на світлу тему",
"management": "Управління",
"preferences": "Ваші уподобання",
"logout": "",
"logout": "Вийти",
"login": "Логін",
"homeBoard": "",
"loggedOut": "",
"loggedOut": "Ви вийшли",
"updateAvailable": ""
}
},
@@ -893,9 +904,9 @@
"number": "Це поле повинно бути менше або дорівнювати {maximum}"
},
"custom": {
"passwordsDoNotMatch": "",
"passwordRequirements": "",
"boardAlreadyExists": "",
"passwordsDoNotMatch": "Паролі не збігаються",
"passwordRequirements": "Пароль не відповідає вимогам",
"boardAlreadyExists": "Дошка з такою назвою вже існує",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
@@ -923,29 +934,29 @@
}
},
"action": {
"create": "",
"edit": "",
"remove": "",
"create": "Нова категорія",
"edit": "Перейменувати категорію",
"remove": "Видалити категорію",
"moveUp": "Рухайся.",
"moveDown": "Вниз.",
"createAbove": "",
"createBelow": ""
},
"create": {
"title": "",
"submit": ""
"title": "Нова категорія",
"submit": "Додати категорію"
},
"remove": {
"title": "",
"message": ""
"title": "Видалити категорію",
"message": "Ви впевнені, що хочете видалити категорію {name}?"
},
"edit": {
"title": "",
"submit": ""
"title": "Перейменувати категорію",
"submit": "Перейменувати категорію"
},
"menu": {
"label": {
"create": "",
"create": "Нова категорія",
"changePosition": "Змінити положення"
}
}
@@ -1137,7 +1148,7 @@
},
"clock": {
"name": "",
"description": "Відображає поточну дату і час.",
"description": "Показує поточні дату і час.",
"option": {
"customTitleToggle": {
"label": "",
@@ -1193,7 +1204,7 @@
"description": "",
"option": {
"showToolbar": {
"label": "Показати панель інструментів для написання націнки"
"label": "Показати панель інструментів для допомоги з розміткою"
},
"allowReadOnlyCheck": {
"label": ""
@@ -1235,9 +1246,9 @@
"deleteRow": ""
},
"align": {
"left": "Ліворуч.",
"left": "Ліворуч",
"center": "Центр",
"right": "Так."
"right": "Праворуч"
},
"popover": {
"clearColor": "",
@@ -1250,7 +1261,7 @@
}
},
"iframe": {
"name": "IFrame",
"name": "iFrame",
"description": "Вставити будь-який контент з інтернету. Деякі вебсайти можуть обмежувати доступ.",
"option": {
"embedUrl": {
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {
@@ -1776,16 +1839,16 @@
"board": {
"action": {
"duplicate": {
"title": "",
"message": "",
"title": "Скопіювати дошку",
"message": "Це продублює дошку {name} з усім її вмістом. Якщо віджети посилаються на інтеграції, які ви не можете використовувати, їх буде видалено.",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Дошку скопійовано",
"message": "Дошку успішно продубльовано"
},
"error": {
"title": "",
"message": ""
"title": "Неможливо продублювати дошку",
"message": "Дошку не може бути продубльовано"
}
}
},
@@ -1806,7 +1869,7 @@
}
},
"oldImport": {
"label": "",
"label": "Імпорт з homarr до версії 1.0.0",
"notification": {
"success": {
"title": "",
@@ -1825,8 +1888,8 @@
"apps": {
"label": "Додатки",
"avoidDuplicates": {
"label": "",
"description": ""
"label": "Уникати дублікатів",
"description": "Ігнорує додатки, якщо додаток з таким самим посиланням вже існує"
},
"onlyImportApps": {
"label": "",
@@ -2108,12 +2171,12 @@
},
"settings": "Налаштування",
"help": {
"label": "Допоможіть!",
"label": "Допомога",
"items": {
"documentation": "Документація",
"submitIssue": "",
"discord": "Розбрат у громаді",
"sourceCode": ""
"submitIssue": "Повідомити про проблему",
"discord": "Discord",
"sourceCode": "Вихідний код"
}
},
"about": "Про програму"
@@ -2140,30 +2203,30 @@
"title": "Твої дошки",
"action": {
"new": {
"label": ""
"label": "Нова дошка"
},
"open": {
"label": ""
"label": "Відкрити дошку"
},
"settings": {
"label": "Налаштування"
},
"setHomeBoard": {
"label": "",
"label": "Встановити як домашню дошку",
"badge": {
"label": "Головна",
"tooltip": ""
"tooltip": "Ця дошка буде вашою домашньою дошкою"
}
},
"setMobileHomeBoard": {
"label": "",
"label": "Встановити як мобільну дошку",
"badge": {
"label": "",
"tooltip": ""
"tooltip": "Ця дошка буде вашою мобільною дошкою"
}
},
"duplicate": {
"label": ""
"label": "Скопіювати дошку"
},
"delete": {
"label": "Видалити назавжди",
@@ -2314,81 +2377,81 @@
"title": "Налаштування",
"notification": {
"success": {
"message": ""
"message": "Налаштування успішно збережено"
},
"error": {
"message": ""
"message": "Не вдалося зберегти налаштування"
}
},
"section": {
"analytics": {
"title": "",
"title": "Аналітика",
"general": {
"title": "",
"text": ""
"title": "Надсилати анонімну аналітику",
"text": "Homarr буде відправляти анонімну аналітику з використанням програмного забезпечення з відкритим вихідним кодом Umami. Він ніколи не збирає жодної особистої інформації і, таким чином, сумісний з GDPR та CCPA. Ми заохочуємо вас увімкнути аналітику, тому що це допомагає нашій команді визначати проблеми та пріоритетні задачі."
},
"widgetData": {
"title": "",
"text": ""
"title": "Дані віджетів",
"text": "Надсилати, які віджети (і їх кількість) ви налаштували. Не містить URL, назви, або будь-які інші дані."
},
"integrationData": {
"title": "",
"text": ""
"title": "Дані інтеграції",
"text": "Надсилати, які віджети (і їх кількість) ви налаштували. Не містить URL, назви, або будь-які інші дані."
},
"usersData": {
"title": "",
"text": ""
"title": "Дані користувачів",
"text": "Надсилати кількість користувачів і чи активували ви SSO"
}
},
"crawlingAndIndexing": {
"title": "",
"warning": "",
"title": "Пошукові сканери та індексація",
"warning": "Увімкнення або вимкнення будь-яких налаштувань тут серйозно вплине на те, як пошукові системи індексуватимуть і скануватимуть вашу сторінку. Будь-яке налаштування є запитом, і пошуковий сканер має застосувати ці налаштування. Будь-які зміни можуть зайняти кілька днів або тижнів. Деякі налаштування можуть залежати від пошукової системи.",
"noIndex": {
"title": "",
"text": ""
"title": "Не індексувати (No Index)",
"text": "Не індексувати сайт у пошукових системах і не показувати його в жодному результаті пошуку"
},
"noFollow": {
"title": "",
"text": ""
"title": "Не переходити (No follow)",
"text": "Не переходьте за посиланнями під час індексації. Якщо вимкнути це, то сканери намагатимуться переходити за всіма посиланнями на Homarr."
},
"noTranslate": {
"title": "",
"text": ""
"title": "Не перекладати (No translate)",
"text": "Якщо мова сайту не є тією, яку користувач, імовірно, захоче прочитати, Google покаже посилання на переклад у результатах пошуку"
},
"noSiteLinksSearchBox": {
"title": "",
"text": ""
"title": "Без вікна пошуку з посиланнями на сайт",
"text": "Google створює вікно пошуку зі просканованими посиланнями разом з іншими прямими посиланнями. Ввімкнення цього налаштування просить Google відключити таке вікно пошуку."
}
},
"board": {
"title": "Дошки",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
"label": "Глобальна домашня дошка",
"mobileLabel": "Глобальна мобільна дошка",
"description": "Тут доступні тільки публічні дошки"
}
},
"search": {
"title": "",
"title": "Пошук",
"defaultSearchEngine": {
"label": "",
"description": ""
"label": "Глобальна система пошуку за замовчуванням",
"description": "Пошукові системи з інтеграцій тут недоступні"
}
},
"appearance": {
"title": "Вигляд",
"defaultColorScheme": {
"label": "",
"label": "Стандартна кольорова тема",
"options": {
"light": "",
"dark": ""
"light": "Світла",
"dark": "Темна"
}
}
},
"culture": {
"title": "",
"title": "Культура",
"defaultLocale": {
"label": ""
"label": "Стандартна мова"
}
}
}
@@ -2485,20 +2548,20 @@
}
},
"about": {
"version": "",
"text": "",
"version": "Версія {version}",
"text": "Homarr — це проєкт із відкритим кодом, керований спільнотою, який підтримується волонтерами. Завдяки цим людям проєкт Homarr розвивається з 2021 року. Наша команда працює над Homarr абсолютно віддалено з різних країн у вільний час без жодної компенсації.",
"accordion": {
"contributors": {
"title": "",
"subtitle": ""
"title": "Учасники",
"subtitle": "{count} - стільки людей розробляє код та Homarr"
},
"translators": {
"title": "",
"subtitle": ""
"title": "Перекладачі",
"subtitle": "{count} - стільки людей допомагають перекладати багатьма мовами"
},
"libraries": {
"title": "",
"subtitle": ""
"title": "Бібліотеки",
"subtitle": "{count} - стільки бібліотек використано в коді Homarr"
}
}
}
@@ -2729,14 +2792,14 @@
},
"mode": {
"appIntegrationBoard": {
"help": "",
"help": "Пошук додатків, інтеграцій, або дошок",
"group": {
"app": {
"title": "Додатки",
"children": {
"action": {
"open": {
"label": ""
"label": "Відкрити посилання на додаток"
},
"edit": {
"label": ""
@@ -2752,7 +2815,7 @@
"children": {
"action": {
"open": {
"label": ""
"label": "Відкрити дошку"
},
"homeBoard": {
"label": ""
@@ -2761,7 +2824,7 @@
"label": ""
},
"settings": {
"label": ""
"label": "Відкрити налаштування"
}
},
"detail": {
@@ -2796,7 +2859,7 @@
}
},
"newBoard": {
"label": ""
"label": "Створити нову дошку"
},
"importBoard": {
"label": ""
@@ -2831,7 +2894,7 @@
"openIn": ""
},
"external": {
"help": "",
"help": "Використовувати зовнішню пошукову систему",
"group": {
"searchEngine": {
"title": "",
@@ -2876,19 +2939,19 @@
"help": {
"group": {
"mode": {
"title": ""
"title": "Режими"
},
"help": {
"title": "Допоможіть!",
"title": "Допомога",
"option": {
"documentation": {
"label": "Документація"
},
"submitIssue": {
"label": ""
"label": "Повідомити про проблему"
},
"discord": {
"label": "Розбрат у громаді"
"label": "Discord"
}
}
}
@@ -2920,7 +2983,7 @@
}
},
"page": {
"help": "",
"help": "Пошук сторінок",
"group": {
"page": {
"title": "",
@@ -2981,7 +3044,7 @@
}
},
"userGroup": {
"help": "",
"help": "Пошук користувачів або груп",
"group": {
"user": {
"title": "Користувачі",

View File

@@ -733,6 +733,14 @@
"password": {
"label": "Mật khẩu",
"newLabel": "Mật khẩu mới"
},
"tokenId": {
"label": "",
"newLabel": ""
},
"realm": {
"label": "",
"newLabel": ""
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": ""
},
"open": {
"label": ""
}
}
},
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": ""
},
"defaultTab": {
"label": ""
},
"sectionIndicatorRequirement": {
"label": ""
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": ""
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
},
"resource": {
"node": {
"name": ""
},
"qemu": {
"name": ""
},
"lxc": {
"name": ""
},
"storage": {
"name": ""
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"storageType": {
"local": "",
"shared": ""
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
}
}
}
},
"common": {

View File

@@ -733,6 +733,14 @@
"password": {
"label": "密碼",
"newLabel": "新密碼"
},
"tokenId": {
"label": "密鑰 ID",
"newLabel": "新密鑰 ID"
},
"realm": {
"label": "領域",
"newLabel": "新領域"
}
}
},
@@ -777,6 +785,9 @@
},
"copy": {
"label": "複製網址"
},
"open": {
"label": ""
}
}
},
@@ -897,7 +908,7 @@
"passwordRequirements": "密碼不符合要求",
"boardAlreadyExists": "此面板名稱已存在",
"invalidFileType": "無效的檔案類型,例如 {expected}",
"invalidFileName": "",
"invalidFileName": "無效的檔案名稱",
"fileTooLarge": "檔案太大,最大大小為 {maxSize}",
"invalidConfiguration": "無效設定",
"groupNameTaken": "用戶組名稱已存在"
@@ -1411,6 +1422,12 @@
},
"fileSystem": {
"label": "顯示檔案系統訊息"
},
"defaultTab": {
"label": "預設頁面"
},
"sectionIndicatorRequirement": {
"label": "部分指示需求"
}
},
"popover": {
@@ -1430,6 +1447,52 @@
"memory": {},
"error": {
"internalServerError": "取得健康狀態失敗"
},
"cluster": {
"summary": {
"cpu": "處理器",
"memory": "記憶體"
},
"resource": {
"node": {
"name": "節點數"
},
"qemu": {
"name": "虛擬機"
},
"lxc": {
"name": "LXCs"
},
"storage": {
"name": "儲存裝置"
}
},
"popover": {
"rightSection": {
"node": "節點",
"vmId": "虛擬機 ID",
"plugin": "插件"
},
"detail": {
"cpu": "核心數",
"memory": "記憶體",
"storage": "儲存裝置",
"uptime": "運行時間",
"haState": "HA 狀態",
"storageType": {
"local": "本機儲存",
"shared": "共享儲存"
}
}
},
"table": {
"header": {
"name": "名稱",
"cpu": "處理器",
"memory": "記憶體",
"node": "節點"
}
}
}
},
"common": {
@@ -2102,7 +2165,7 @@
"docker": "Docker",
"logs": "Logs",
"api": "API",
"certificates": "",
"certificates": "憑證",
"tasks": "任務"
}
},
@@ -2156,10 +2219,10 @@
}
},
"setMobileHomeBoard": {
"label": "",
"label": "設定為移動裝置面板",
"badge": {
"label": "",
"tooltip": ""
"label": "移動裝置",
"tooltip": "此面板將顯示為移動裝置主面板"
}
},
"duplicate": {
@@ -2199,10 +2262,10 @@
"item": {
"language": "語言與地區",
"board": {
"title": "",
"title": "主面板",
"type": {
"general": "",
"mobile": ""
"general": "一般",
"mobile": "移動裝置"
}
},
"defaultSearchEngine": "預設搜尋引擎",
@@ -2364,7 +2427,7 @@
"title": "面板",
"homeBoard": {
"label": "全局主面板",
"mobileLabel": "",
"mobileLabel": "全局移動裝置主面板",
"description": "只有公開面板可供選擇"
}
},
@@ -2710,7 +2773,7 @@
"label": "Logs"
},
"certificates": {
"label": ""
"label": "憑證"
}
},
"settings": {
@@ -2758,7 +2821,7 @@
"label": "設定為主面板"
},
"mobileBoard": {
"label": ""
"label": "設定為移動裝置面板"
},
"settings": {
"label": "開啟設定"
@@ -3110,39 +3173,39 @@
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"title": "可信任的憑證",
"description": "由 Homarr 用於集成中請求數據",
"noResults": {
"title": ""
"title": "尚無憑證"
},
"expires": ""
"expires": "到期 {when}"
}
},
"action": {
"create": {
"label": "",
"label": "新增憑證",
"notification": {
"success": {
"title": "",
"message": ""
"title": "憑證已新增",
"message": "此憑證已新增成功"
},
"error": {
"title": "",
"message": ""
"title": "新增憑證失敗",
"message": "此憑證無法新增"
}
}
},
"remove": {
"label": "",
"confirm": "",
"label": "移除憑證",
"confirm": "確定要移除這個憑證?",
"notification": {
"success": {
"title": "",
"message": ""
"title": "憑證已移除",
"message": "此憑證已移除成功"
},
"error": {
"title": "",
"message": ""
"title": "憑證未被移除",
"message": "此憑證無法被移除"
}
}
}

View File

@@ -29,12 +29,12 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@mantine/dates": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"@mantine/core": "^7.16.1",
"@mantine/dates": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"@tabler/icons-react": "^3.29.0",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0"
},

View File

@@ -26,7 +26,6 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@extractus/feed-extractor": "^7.1.3",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
@@ -41,28 +40,28 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"@tiptap/extension-color": "2.11.2",
"@tiptap/extension-highlight": "2.11.2",
"@tiptap/extension-image": "2.11.2",
"@tiptap/extension-link": "^2.11.2",
"@tiptap/extension-table": "2.11.2",
"@tiptap/extension-table-cell": "2.11.2",
"@tiptap/extension-table-header": "2.11.2",
"@tiptap/extension-table-row": "2.11.2",
"@tiptap/extension-task-item": "2.11.2",
"@tiptap/extension-task-list": "2.11.2",
"@tiptap/extension-text-align": "2.11.2",
"@tiptap/extension-text-style": "2.11.2",
"@tiptap/extension-underline": "2.11.2",
"@tiptap/react": "^2.11.2",
"@tiptap/starter-kit": "^2.11.2",
"@mantine/core": "^7.16.1",
"@mantine/hooks": "^7.16.1",
"@tabler/icons-react": "^3.29.0",
"@tiptap/extension-color": "2.11.3",
"@tiptap/extension-highlight": "2.11.3",
"@tiptap/extension-image": "2.11.3",
"@tiptap/extension-link": "^2.11.3",
"@tiptap/extension-table": "2.11.3",
"@tiptap/extension-table-cell": "2.11.3",
"@tiptap/extension-table-header": "2.11.3",
"@tiptap/extension-table-row": "2.11.3",
"@tiptap/extension-task-item": "2.11.3",
"@tiptap/extension-task-list": "2.11.3",
"@tiptap/extension-text-align": "2.11.3",
"@tiptap/extension-text-style": "2.11.3",
"@tiptap/extension-underline": "2.11.3",
"@tiptap/react": "^2.11.3",
"@tiptap/starter-kit": "^2.11.3",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.5",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"video.js": "^8.21.0"

View File

@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { CalendarEvent } from "@homarr/integrations/types";
@@ -12,8 +13,22 @@ import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, integrationIds, options }: WidgetComponentProps<"calendar">) {
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
}
interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
month: Date;
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
{
integrationIds,
@@ -28,6 +43,19 @@ export default function CalendarWidget({ isEditMode, integrationIds, options }:
retry: false,
},
);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
};
interface CalendarBaseProps {
isEditMode: boolean;
events: RouterOutputs["widget"]["calendar"]["findAllEvents"];
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const [firstDayOfWeek] = clientApi.user.getFirstDayOfWeekForUserOrDefault.useSuspenseQuery();
@@ -37,6 +65,7 @@ export default function CalendarWidget({ isEditMode, integrationIds, options }:
defaultDate={new Date()}
onPreviousMonth={setMonth}
onNextMonth={setMonth}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
@@ -95,4 +124,4 @@ export default function CalendarWidget({ isEditMode, integrationIds, options }:
}}
/>
);
}
};

View File

@@ -27,4 +27,5 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar"
}),
})),
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
integrationsRequired: false,
}).withDynamicImport(() => import("./component"));

View File

@@ -29,6 +29,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
options: WidgetOptionsRecord;
errors?: Partial<
Record<

View File

@@ -181,9 +181,7 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
</Modal>
</Box>
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
{healthInfo.cpuTemp && options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
)}
{options.cpu && <CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />}
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
</Flex>
{
@@ -225,7 +223,7 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
<Group gap="1cqmin">
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
<Text className="health-monitoring-disk-status-value" size="4cqmin">
{disk.overallStatus}
{disk.overallStatus ? disk.overallStatus : "N/A"}
</Text>
</Group>
</Flex>

Some files were not shown because too many files have changed in this diff Show More