Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa80c9b427 | ||
|
|
10cc29c3a5 | ||
|
|
42e3422c57 | ||
|
|
f83ef563b0 |
No files matched your search
@@ -33,7 +33,6 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
- 1.67.0
|
|
||||||
- 1.66.1
|
- 1.66.1
|
||||||
- 1.66.0
|
- 1.66.0
|
||||||
- 1.65.0
|
- 1.65.0
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ name: "[Deployment] Automatic Weekly Release"
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
timezone: Europe/Zurich
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
send-notifications:
|
send-notifications:
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **analytics:** migrate from Umami to PostHog server-side analytics ([#5983](https://github.com/homarr-labs/homarr/issues/5983)) ([d059023](https://github.com/homarr-labs/homarr/commit/d059023b51a323df027c1cf033454440bd0d7e57))
|
|
||||||
* Calculate and display total CPU and memory usage ([#5911](https://github.com/homarr-labs/homarr/issues/5911)) ([d9cbd54](https://github.com/homarr-labs/homarr/commit/d9cbd5427d0e5be3e5dbecca8d6069dc8074d810))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **auth:** isolate session cookies from other Auth.js apps on same host ([#5996](https://github.com/homarr-labs/homarr/issues/5996)) ([e1a245f](https://github.com/homarr-labs/homarr/commit/e1a245ff2d8520d08957c4a7dbe01d039607a973))
|
|
||||||
* **deps:** update tailwindcss monorepo to ^4.3.1 ([#5988](https://github.com/homarr-labs/homarr/issues/5988)) ([78b6e52](https://github.com/homarr-labs/homarr/commit/78b6e520ac095129398d3da8f2441df408e93e9f))
|
|
||||||
* **downloads:** sort stalled torrents last when sorting by finish time ([#6011](https://github.com/homarr-labs/homarr/issues/6011)) ([c56d9d6](https://github.com/homarr-labs/homarr/commit/c56d9d6894c717b5c70ab75d6a100d4d1071c2d3)), closes [#5416](https://github.com/homarr-labs/homarr/issues/5416)
|
|
||||||
|
|
||||||
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
-14
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
|
|||||||
|
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
|
||||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
|
||||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
|
||||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
RUN cd apps/nextjs/.next/server && \
|
|
||||||
find . -name '*.map' | while IFS= read -r map; do \
|
|
||||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
|
||||||
mkdir -p "$(dirname "$dest")"; \
|
|
||||||
cp "$map" "$dest"; \
|
|
||||||
done
|
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -75,9 +64,6 @@ ENV DB_DRIVER='better-sqlite3'
|
|||||||
ENV AUTH_PROVIDERS='credentials'
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
ENV REDIS_IS_EXTERNAL='false'
|
ENV REDIS_IS_EXTERNAL='false'
|
||||||
ENV NODE_ENV='production'
|
ENV NODE_ENV='production'
|
||||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
|
||||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
ENV NODE_OPTIONS='--enable-source-maps'
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
productionBrowserSourceMaps: true,
|
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
//reactCompiler: true,
|
//reactCompiler: true,
|
||||||
/** We already do typechecking as separate tasks in CI */
|
/** We already do typechecking as separate tasks in CI */
|
||||||
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
|
|||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
|
||||||
serverSourceMaps: true,
|
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
preloadEntriesOnStart: false,
|
preloadEntriesOnStart: false,
|
||||||
|
|||||||
-2
@@ -131,7 +131,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
|
||||||
error.data.reason === "hostnameMismatch" ? (
|
error.data.reason === "hostnameMismatch" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||||
@@ -141,7 +140,6 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
|
|||||||
) : null}
|
) : null}
|
||||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="default"
|
variant="default"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -149,20 +149,16 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DockerTableProps {
|
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DockerTable({ initialData }: DockerTableProps) {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData,
|
initialData: { containers, timestamp },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
@@ -180,11 +176,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const containers = data?.containers ?? [];
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: containers,
|
data: data.containers,
|
||||||
state: { isLoading: isFetching },
|
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
@@ -197,7 +190,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
positionGlobalFilter: "right",
|
positionGlobalFilter: "right",
|
||||||
mantineSearchTextInputProps: {
|
mantineSearchTextInputProps: {
|
||||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||||
style: { minWidth: 300 },
|
style: { minWidth: 300 },
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
|||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tDocker("title")}</Title>
|
<Title order={1}>{tDocker("title")}</Title>
|
||||||
<DockerTable initialData={{ containers, timestamp }} />
|
<DockerTable containers={containers} timestamp={timestamp} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
|
|
||||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
|
||||||
|
|
||||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
|
||||||
defaultTitle: (t) => t("docker.title"),
|
|
||||||
size: 1400,
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,6 @@ import { UnstyledButton } from "@mantine/core";
|
|||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { env } from "@homarr/docker/env";
|
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||||
@@ -12,10 +11,9 @@ import { UpdateIndicator } from "./update";
|
|||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isAdmin = session?.user.permissions.includes("admin");
|
const isAdmin = session?.user.permissions.includes("admin");
|
||||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
|
||||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import { useCallback, useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||||
import {
|
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||||
IconBrandDocker,
|
|
||||||
IconCheck,
|
|
||||||
IconHome,
|
|
||||||
IconLogin,
|
|
||||||
IconLogout,
|
|
||||||
IconSettings,
|
|
||||||
IconTool,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
@@ -25,16 +17,14 @@ import { Link } from "@homarr/ui";
|
|||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
|
||||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||||
isDockerEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||||
@@ -44,7 +34,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
|||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal } = useModalAction(LogoutModal);
|
const { openModal } = useModalAction(LogoutModal);
|
||||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
await signOut({
|
await signOut({
|
||||||
@@ -91,11 +80,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
|||||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||||
{t("management")}
|
{t("management")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{isDockerEnabled && (
|
|
||||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
|
||||||
{t("docker")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.16.0",
|
||||||
"pnpm": ">=11.7.0"
|
"pnpm": ">=11.6.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0"
|
"packageManager": "pnpm@11.6.0"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.67.0",
|
"version": "1.65.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.16.0",
|
||||||
"pnpm": ">=11.7.0"
|
"pnpm": ">=11.6.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -15,36 +15,20 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(iconsFindSchema)
|
.input(iconsFindSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
|
||||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
|
||||||
|
|
||||||
let whereCondition = undefined;
|
|
||||||
if (term.length > 0) {
|
|
||||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icons: await ctx.db.query.iconRepositories.findMany({
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
with: {
|
with: {
|
||||||
icons: {
|
icons: {
|
||||||
columns: { id: true, name: true, url: true },
|
columns: {
|
||||||
where: whereCondition,
|
id: true,
|
||||||
orderBy: (table, { asc, sql }) => {
|
name: true,
|
||||||
// ponytail: SVG first, then alphabetical; with a search term,
|
url: true,
|
||||||
// prepend a relevance tier (exact > prefix > substring).
|
|
||||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
|
||||||
const nameAsc = asc(table.name);
|
|
||||||
if (term.length === 0) {
|
|
||||||
return [svgFirst, nameAsc];
|
|
||||||
}
|
|
||||||
const tier = sql`CASE
|
|
||||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
|
||||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
|
||||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END`;
|
|
||||||
return [tier, svgFirst, nameAsc];
|
|
||||||
},
|
},
|
||||||
|
where:
|
||||||
|
(input.searchText?.length ?? 0) > 0
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||||
|
: undefined,
|
||||||
limit: input.limitPerGroup,
|
limit: input.limitPerGroup,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||||
import {
|
import {
|
||||||
@@ -15,8 +14,6 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
|
|||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const logger = createLogger({ module: "beszelRouter" });
|
|
||||||
|
|
||||||
export const beszelRouter = createTRPCRouter({
|
export const beszelRouter = createTRPCRouter({
|
||||||
getSystems: publicProcedure
|
getSystems: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -28,9 +25,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
const results = await Promise.all(
|
||||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
|
||||||
const settled = await Promise.allSettled(
|
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||||
@@ -45,29 +40,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const results = settled.map((result, index) => {
|
|
||||||
if (result.status === "fulfilled") return result.value;
|
|
||||||
const integration = ctx.integrations[index];
|
|
||||||
logger.warn("getSystems integration failed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration?.id,
|
|
||||||
error: result.reason,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
integrationId: integration?.id ?? "unknown",
|
|
||||||
integrationName: integration?.name ?? "unknown",
|
|
||||||
integrationUrl: integration?.url ?? "",
|
|
||||||
systems: [],
|
|
||||||
updatedAt: new Date(0),
|
|
||||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
logger.debug("getSystems completed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationIds,
|
|
||||||
resultCount: results.length,
|
|
||||||
errorCount: results.filter((r) => "error" in r).length,
|
|
||||||
});
|
|
||||||
return results;
|
return results;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -111,14 +83,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
const results = await Promise.all(
|
||||||
logger.debug("getAlerts called", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationIds,
|
|
||||||
includeHistory: input.includeHistory,
|
|
||||||
maxHistoryItems: input.maxHistoryItems,
|
|
||||||
});
|
|
||||||
const settled = await Promise.allSettled(
|
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||||
includeHistory: input.includeHistory,
|
includeHistory: input.includeHistory,
|
||||||
@@ -135,7 +100,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
|
||||||
alerts: alertsResult.data.alerts,
|
alerts: alertsResult.data.alerts,
|
||||||
history: alertsResult.data.history,
|
history: alertsResult.data.history,
|
||||||
systemNameMap,
|
systemNameMap,
|
||||||
@@ -143,30 +107,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const results = settled.map((result, index) => {
|
|
||||||
if (result.status === "fulfilled") return result.value;
|
|
||||||
const integration = ctx.integrations[index];
|
|
||||||
logger.warn("getAlerts integration failed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration?.id,
|
|
||||||
error: result.reason,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
integrationId: integration?.id ?? "unknown",
|
|
||||||
integrationName: integration?.name ?? "unknown",
|
|
||||||
alerts: [],
|
|
||||||
history: [],
|
|
||||||
systemNameMap: {},
|
|
||||||
updatedAt: new Date(0),
|
|
||||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
logger.debug("getAlerts completed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationIds,
|
|
||||||
resultCount: results.length,
|
|
||||||
errorCount: results.filter((r) => "error" in r).length,
|
|
||||||
});
|
|
||||||
return results;
|
return results;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -224,41 +164,13 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
if (!integration) {
|
if (!integration) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||||
}
|
}
|
||||||
logger.debug("getSystemStats called", {
|
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
systemId: input.systemId,
|
||||||
timePeriod: input.timePeriod,
|
timePeriod: input.timePeriod,
|
||||||
includeDocker: input.includeDocker,
|
includeDocker: input.includeDocker,
|
||||||
});
|
});
|
||||||
try {
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||||
systemId: input.systemId,
|
|
||||||
timePeriod: input.timePeriod,
|
|
||||||
includeDocker: input.includeDocker,
|
|
||||||
});
|
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
logger.debug("getSystemStats completed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
});
|
|
||||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("getSystemStats failed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemStats: [],
|
|
||||||
containerStats: [],
|
|
||||||
updatedAt: new Date(0),
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
subscribeSystemStats: publicProcedure
|
subscribeSystemStats: publicProcedure
|
||||||
@@ -278,11 +190,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
emit.error(new Error("No Beszel integration configured"));
|
emit.error(new Error("No Beszel integration configured"));
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
logger.debug("subscribeSystemStats opened", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
@@ -292,12 +199,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
logger.warn("subscribeSystemStats error", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
emit.error(error);
|
emit.error(error);
|
||||||
} else {
|
} else {
|
||||||
@@ -309,11 +210,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
logger.debug("subscribeSystemStats closed", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -21,7 +18,7 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -37,6 +34,7 @@ interface IconPickerProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -52,30 +50,29 @@ export const IconPicker = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
const [value, setValue] = useUncontrolled({
|
||||||
const [query, setQuery] = useState("");
|
value: propsValue,
|
||||||
|
onChange,
|
||||||
// ponytail: reset the typed query when the committed value changes
|
});
|
||||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
const [search, setSearch] = useUncontrolled({
|
||||||
useEffect(() => {
|
value,
|
||||||
setQuery("");
|
onChange: (value) => {
|
||||||
}, [value]);
|
setValue(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||||
|
value: propsValue ?? null,
|
||||||
|
});
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
|
||||||
|
|
||||||
// ponytail: only search when the typed query differs from the committed
|
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||||
// value. On mount the input shows the existing URL (edit mode), which
|
|
||||||
// would never match the bare filenames in the icon table.
|
|
||||||
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
|
||||||
|
|
||||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
const { data } = clientApi.icon.findIcons.useQuery({
|
||||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
searchText: debouncedSearch,
|
||||||
{ searchText },
|
});
|
||||||
{ placeholderData: (prev) => prev },
|
|
||||||
);
|
|
||||||
|
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
@@ -93,15 +90,25 @@ export const IconPicker = ({
|
|||||||
>
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const value = item.url;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(item.url);
|
setPreviewUrl(value);
|
||||||
setQuery("");
|
setSearch(value);
|
||||||
|
setValue(value);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
<Card
|
||||||
|
p="sm"
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
className={classes.iconCard}
|
||||||
|
>
|
||||||
<Box w={25} h={25}>
|
<Box w={25} h={25}>
|
||||||
<Image src={item.url} w={25} h={25} />
|
<Image src={item.url} w={25} h={25} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -123,48 +130,6 @@ export const IconPicker = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
|
||||||
const inputValue = query || value || "";
|
|
||||||
// ponytail: show the existing icon as a left-section preview.
|
|
||||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
|
||||||
// ponytail: prop override -> real count -> loading text.
|
|
||||||
const placeholderText =
|
|
||||||
placeholder ||
|
|
||||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
|
||||||
tCommon("iconPicker.headerLoading");
|
|
||||||
|
|
||||||
const renderDropdown = () => {
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Stack align="center" gap="xs" p="md">
|
|
||||||
<IconSearchOff size={32} stroke={1.5} />
|
|
||||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
|
||||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
|
||||||
{tCommon("action.tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
return Array(15)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, index) => (
|
|
||||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
|
||||||
<Skeleton height={25} visible />
|
|
||||||
</Combobox.Option>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (totalOptions > 0) {
|
|
||||||
return <Stack gap={4}>{groups}</Stack>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack align="center" gap="xs" p="md">
|
|
||||||
<IconMoodEmpty size={32} stroke={1.5} />
|
|
||||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -172,11 +137,16 @@ export const IconPicker = ({
|
|||||||
<InputBase
|
<InputBase
|
||||||
flex={1}
|
flex={1}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
leftSection={leftSection}
|
leftSection={
|
||||||
value={inputValue}
|
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
setQuery(event.currentTarget.value);
|
combobox.updateSelectedOptionIndex();
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setValue(event.currentTarget.value);
|
||||||
|
setPreviewUrl(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
@@ -186,21 +156,25 @@ export const IconPicker = ({
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
setQuery("");
|
setPreviewUrl(value);
|
||||||
|
setSearch(value || "");
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={(medias) => {
|
onSuccess={(medias) => {
|
||||||
const first = medias.at(0);
|
const first = medias.at(0);
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(first.url);
|
setValue(first.url);
|
||||||
|
setPreviewUrl(first.url);
|
||||||
|
setSearch(first.url);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -216,7 +190,17 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{renderDropdown()}
|
{totalOptions > 0 ? (
|
||||||
|
<Stack gap={4}>{groups}</Stack>
|
||||||
|
) : (
|
||||||
|
Array(15)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index: number) => (
|
||||||
|
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||||
|
<Skeleton height={25} visible />
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@@ -225,8 +209,8 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
const shouldShowPreview = (value: string | null): value is string => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return !localizationPathRegex.test(value);
|
return !localizationPathRegex.test(value);
|
||||||
};
|
};
|
||||||
@@ -46,13 +46,10 @@ export class BeszelIntegration extends Integration {
|
|||||||
private async authenticateAsync(): Promise<BeszelSession> {
|
private async authenticateAsync(): Promise<BeszelSession> {
|
||||||
const existingSession = await this.sessionStore.getAsync();
|
const existingSession = await this.sessionStore.getAsync();
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
|
||||||
return existingSession;
|
return existingSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -60,28 +57,23 @@ export class BeszelIntegration extends Integration {
|
|||||||
password: this.getSecretValue("password"),
|
password: this.getSecretValue("password"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
|
|
||||||
throw new ResponseError(response);
|
throw new ResponseError(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as BeszelAuthResponse;
|
const data = (await response.json()) as BeszelAuthResponse;
|
||||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||||
await this.sessionStore.setAsync(session);
|
await this.sessionStore.setAsync(session);
|
||||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||||
const method = options.method ?? "GET";
|
|
||||||
const start = performance.now();
|
|
||||||
let session = await this.authenticateAsync();
|
let session = await this.authenticateAsync();
|
||||||
|
|
||||||
const doFetch = (token: string) =>
|
const doFetch = (token: string) =>
|
||||||
fetchWithTrustedCertificatesAsync(url, {
|
fetchWithTrustedCertificatesAsync(url, {
|
||||||
method,
|
method: options.method ?? "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token,
|
Authorization: token,
|
||||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||||
@@ -90,42 +82,14 @@ export class BeszelIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response = await doFetch(session.token);
|
let response = await doFetch(session.token);
|
||||||
logger.debug("Beszel response", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
method,
|
|
||||||
path: url.pathname,
|
|
||||||
status: response.status,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
attempt: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
logger.warn("Beszel 401, clearing session and retrying", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
method,
|
|
||||||
path: url.pathname,
|
|
||||||
});
|
|
||||||
await this.sessionStore.clearAsync();
|
await this.sessionStore.clearAsync();
|
||||||
session = await this.authenticateAsync();
|
session = await this.authenticateAsync();
|
||||||
response = await doFetch(session.token);
|
response = await doFetch(session.token);
|
||||||
logger.debug("Beszel response (after re-auth)", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
method,
|
|
||||||
path: url.pathname,
|
|
||||||
status: response.status,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
attempt: 2,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.warn("Beszel request failed", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
method,
|
|
||||||
path: url.pathname,
|
|
||||||
status: response.status,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
});
|
|
||||||
throw new ResponseError(response);
|
throw new ResponseError(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +257,6 @@ export class BeszelIntegration extends Integration {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const session = await this.authenticateAsync();
|
const session = await this.authenticateAsync();
|
||||||
const sseUrl = this.url("/api/realtime");
|
const sseUrl = this.url("/api/realtime");
|
||||||
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
|
|
||||||
|
|
||||||
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||||
headers: { Authorization: session.token },
|
headers: { Authorization: session.token },
|
||||||
@@ -301,14 +264,8 @@ export class BeszelIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!sseResponse.ok || !sseResponse.body) {
|
if (!sseResponse.ok || !sseResponse.body) {
|
||||||
logger.warn("Beszel SSE connection failed", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
systemId,
|
|
||||||
status: sseResponse.status,
|
|
||||||
});
|
|
||||||
throw new ResponseError(sseResponse);
|
throw new ResponseError(sseResponse);
|
||||||
}
|
}
|
||||||
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
|
|
||||||
|
|
||||||
const reader = sseResponse.body.getReader();
|
const reader = sseResponse.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -365,10 +322,6 @@ export class BeszelIntegration extends Integration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
logger.warn("Beszel SSE stream ended unexpectedly", {
|
|
||||||
integrationId: this.integration.id,
|
|
||||||
systemId,
|
|
||||||
});
|
|
||||||
throw new Error("Beszel SSE stream ended unexpectedly");
|
throw new Error("Beszel SSE stream ended unexpectedly");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -132,9 +132,8 @@ const happyResponder = (method: string) => {
|
|||||||
aggregations: emptyAggregations(),
|
aggregations: emptyAggregations(),
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 1,
|
end: 1,
|
||||||
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
|
legend: [],
|
||||||
legend: ["time", "available"],
|
data: [[1000, 8_000_000_000]],
|
||||||
data: [[1000, 6_000_000_000]],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cputemp",
|
name: "cputemp",
|
||||||
@@ -195,17 +194,15 @@ describe("TrueNasIntegration", () => {
|
|||||||
version: "TrueNAS-SCALE-24.10",
|
version: "TrueNAS-SCALE-24.10",
|
||||||
cpuModelName: "Intel Xeon",
|
cpuModelName: "Intel Xeon",
|
||||||
cpuTemp: 45,
|
cpuTemp: 45,
|
||||||
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
|
memAvailableInBytes: 16_000_000_000,
|
||||||
memAvailableInBytes: 6_000_000_000,
|
memUsedInBytes: 8_000_000_000,
|
||||||
memUsedInBytes: 10_000_000_000,
|
|
||||||
uptime: 3600,
|
uptime: 3600,
|
||||||
rebootRequired: false,
|
rebootRequired: false,
|
||||||
availablePkgUpdates: 0,
|
availablePkgUpdates: 0,
|
||||||
loadAverage: null,
|
loadAverage: null,
|
||||||
gpu: [],
|
gpu: [],
|
||||||
network: { up: 20_000, down: 10_000 },
|
network: { up: 20_000, down: 10_000 },
|
||||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
|
||||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
|
||||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,19 +37,14 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||||
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||||
|
|
||||||
// The "memory" reporting graph reports available (free) memory, its legend is ["time", "available"].
|
|
||||||
// Used memory is therefore the physical total minus what is still available (clamped against timing skew).
|
|
||||||
const memAvailableInBytes = memoryData[1] ?? 0; // Index 0 is the UNIX timestamp, Index 1 is available bytes
|
|
||||||
const memUsedInBytes = Math.max(systemInformation.physmem - memAvailableInBytes, 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
||||||
memAvailableInBytes,
|
memAvailableInBytes: systemInformation.physmem,
|
||||||
memUsedInBytes,
|
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => ({
|
||||||
deviceName: dataset.name,
|
deviceName: dataset.name,
|
||||||
available: `${dataset.free}`, // free space left on the pool
|
available: `${dataset.size}`, // TODO: can we use number instead of string here?
|
||||||
used: `${dataset.allocated}`,
|
used: `${dataset.allocated}`,
|
||||||
percentage: (dataset.allocated / dataset.size) * 100,
|
percentage: (dataset.allocated / dataset.size) * 100,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
|||||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||||
deviceName: disk.name,
|
deviceName: disk.name,
|
||||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
|
||||||
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
|
||||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||||
})),
|
})),
|
||||||
smart: systemInfo.array.disks.map((disk) => ({
|
smart: systemInfo.array.disks.map((disk) => ({
|
||||||
|
|||||||
@@ -415,18 +415,8 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||||
// Set first, then publish. If publish fails the storage is still consistent
|
await publisher.publish(channelName, superjson.stringify(data));
|
||||||
// and subscribers will catch up on the next event.
|
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||||
const payload = superjson.stringify({ data, timestamp: new Date() });
|
|
||||||
await getSetClient.set(channelName, payload);
|
|
||||||
try {
|
|
||||||
await publisher.publish(channelName, superjson.stringify(data));
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
|
||||||
channel: channelName,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setAsync: async (data: TData) => {
|
setAsync: async (data: TData) => {
|
||||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type {
|
import type {
|
||||||
BeszelAlert,
|
BeszelAlert,
|
||||||
@@ -14,8 +13,6 @@ import type {
|
|||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
|
||||||
|
|
||||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +56,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration) {
|
async requestAsync(integration) {
|
||||||
const start = performance.now();
|
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systems = await instance.getSystemsAsync();
|
const systems = await instance.getSystemsAsync();
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
@@ -68,16 +64,9 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
return mapToSystemRow(system, details);
|
return mapToSystemRow(system, details);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
logger.debug("beszelSystems fetch completed", {
|
|
||||||
integrationId: integration.id,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
count: enriched.length,
|
|
||||||
});
|
|
||||||
return enriched;
|
return enriched;
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
fallbackToStaleOnError: true,
|
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
|
||||||
queryKey: "beszelSystems",
|
queryKey: "beszelSystems",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,21 +81,12 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ includeHistory: boolean; maxHistoryItems: number }
|
{ includeHistory: boolean; maxHistoryItems: number }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const start = performance.now();
|
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const alerts = await instance.getAlertsAsync();
|
const alerts = await instance.getAlertsAsync();
|
||||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||||
logger.debug("beszelAlerts fetch completed", {
|
|
||||||
integrationId: integration.id,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
alertsCount: alerts.length,
|
|
||||||
historyCount: history.length,
|
|
||||||
});
|
|
||||||
return { alerts, history };
|
return { alerts, history };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(15, "seconds"),
|
cacheDuration: dayjs.duration(15, "seconds"),
|
||||||
fallbackToStaleOnError: true,
|
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
|
||||||
queryKey: "beszelAlerts",
|
queryKey: "beszelAlerts",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,8 +95,8 @@ const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSec
|
|||||||
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
||||||
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
||||||
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
||||||
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
|
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
|
||||||
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
|
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BeszelStatsData {
|
export interface BeszelStatsData {
|
||||||
@@ -130,26 +110,15 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const start = performance.now();
|
|
||||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||||
const containerStats = input.includeDocker
|
const containerStats = input.includeDocker
|
||||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
logger.debug("beszelStats fetch completed", {
|
|
||||||
integrationId: integration.id,
|
|
||||||
systemId: input.systemId,
|
|
||||||
timePeriod: input.timePeriod,
|
|
||||||
durationMs: Math.round(performance.now() - start),
|
|
||||||
systemStatsCount: systemStats.length,
|
|
||||||
containerStatsCount: containerStats.length,
|
|
||||||
});
|
|
||||||
return { systemStats, containerStats };
|
return { systemStats, containerStats };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(60, "seconds"),
|
cacheDuration: dayjs.duration(60, "seconds"),
|
||||||
fallbackToStaleOnError: true,
|
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
|
||||||
queryKey: "beszelStats",
|
queryKey: "beszelStats",
|
||||||
cacheDurationForInput(input) {
|
cacheDurationForInput(input) {
|
||||||
const config = timePeriodConfig[input.timePeriod];
|
const config = timePeriodConfig[input.timePeriod];
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
|||||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||||
fallbackToStaleOnError?: boolean;
|
|
||||||
retry?: { attempts?: number; delayMs?: number };
|
|
||||||
isValid?: (data: TData) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCachedIntegrationRequestHandler = <
|
export const createCachedIntegrationRequestHandler = <
|
||||||
@@ -38,9 +35,6 @@ export const createCachedIntegrationRequestHandler = <
|
|||||||
return await options.requestAsync(input.integration, input.options);
|
return await options.requestAsync(input.integration, input.options);
|
||||||
},
|
},
|
||||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
|
||||||
retry: options.retry,
|
|
||||||
isValid: options.isValid,
|
|
||||||
createRedisChannel(input, handlerOptions) {
|
createRedisChannel(input, handlerOptions) {
|
||||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||||
|
|
||||||
type FetchResult<TData> = { data: TData; timestamp: Date };
|
|
||||||
type InFlightEntry = Promise<FetchResult<unknown>>;
|
|
||||||
|
|
||||||
const inFlightFetches = new Map<string, InFlightEntry>();
|
|
||||||
|
|
||||||
const isAbortedError = (error: unknown): boolean => {
|
|
||||||
if (!(error instanceof Error)) return false;
|
|
||||||
return error.name === "AbortError" || error.name === "TimeoutError";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||||
// Unique key for this request handler
|
// Unique key for this request handler
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
@@ -27,70 +17,28 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
|||||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
fallbackToStaleOnError?: boolean;
|
fallbackToStaleOnError?: boolean;
|
||||||
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
|
||||||
// AbortError / TimeoutError are never retried.
|
|
||||||
retry?: { attempts?: number; delayMs?: number };
|
|
||||||
// Predicate to decide whether a fetched response is worth caching.
|
|
||||||
// Return false to skip the cache write (next call will refetch).
|
|
||||||
isValid?: (data: TData) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
|
||||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
options: Options<TData, TInput>,
|
options: Options<TData, TInput>,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
handler: (input: TInput) => {
|
handler: (input: TInput) => {
|
||||||
const channel = options.createRedisChannel(input, options);
|
const channel = options.createRedisChannel(input, options);
|
||||||
const retryConfig = { ...defaultRetry, ...options.retry };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||||
const channelData = await channel.getAsync();
|
const channelData = await channel.getAsync();
|
||||||
|
|
||||||
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
const requestNewDataAsync = async () => {
|
||||||
const inflight = inFlightFetches.get(channel.name);
|
try {
|
||||||
if (inflight) {
|
const data = await options.requestAsync(input);
|
||||||
logger.debug("Cached request handler joining in-flight fetch", {
|
await channel.publishAndUpdateLastStateAsync(data);
|
||||||
channel: channel.name,
|
return {
|
||||||
queryKey: options.queryKey,
|
data,
|
||||||
});
|
timestamp: new Date(),
|
||||||
return inflight as Promise<FetchResult<TData>>;
|
};
|
||||||
}
|
} catch (error) {
|
||||||
|
|
||||||
const promise = (async (): Promise<FetchResult<TData>> => {
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const data = await options.requestAsync(input);
|
|
||||||
if (options.isValid && !options.isValid(data)) {
|
|
||||||
logger.warn("Cached request handler received invalid data, not caching", {
|
|
||||||
channel: channel.name,
|
|
||||||
queryKey: options.queryKey,
|
|
||||||
attempt,
|
|
||||||
});
|
|
||||||
return { data, timestamp: new Date() };
|
|
||||||
}
|
|
||||||
await channel.publishAndUpdateLastStateAsync(data);
|
|
||||||
return { data, timestamp: new Date() };
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
logger.warn("Cached request handler fetch failed, retrying", {
|
|
||||||
channel: channel.name,
|
|
||||||
queryKey: options.queryKey,
|
|
||||||
attempt,
|
|
||||||
nextDelayMs: retryConfig.delayMs,
|
|
||||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
|
||||||
});
|
|
||||||
await sleep(retryConfig.delayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.fallbackToStaleOnError && channelData) {
|
if (options.fallbackToStaleOnError && channelData) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
new ErrorWithMetadata(
|
new ErrorWithMetadata(
|
||||||
@@ -98,21 +46,15 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
|||||||
{
|
{
|
||||||
channel: channel.name,
|
channel: channel.name,
|
||||||
queryKey: options.queryKey,
|
queryKey: options.queryKey,
|
||||||
attempts: retryConfig.attempts,
|
|
||||||
},
|
},
|
||||||
{ cause: lastError },
|
{ cause: error },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return channelData;
|
return channelData;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError;
|
throw error;
|
||||||
})().finally(() => {
|
}
|
||||||
inFlightFetches.delete(channel.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
|
||||||
return promise;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (forceUpdate) {
|
if (forceUpdate) {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "发送匿名分析",
|
"title": "发送匿名分析",
|
||||||
"text": "Homarr 将使用开源软件 Umami 发送匿名分析数据。启用后,我们会收集:Homarr 版本、数据库类型(MySQL/PostgreSQL/SQLite)、Docker/Kubernetes 状态、已启用的身份验证方法以及各项功能的数量。所有数据均为匿名且汇总收集——绝不收集任何个人信息、URL、名称或可识别数据。这有助于我们的开源团队了解使用模式并确定开发优先级。"
|
"text": "Homarr 将使用开源软件 Umami 发送匿名分析数据。启用后,我们会收集:Homarr 版本、数据库类型(MySQL/PostgreSQL/SQLite)、Docker/Kubernetes 状态、已启用的身份验证方法以及各项功能的数量。所有数据均为匿名且汇总收集——绝不收集任何个人信息、URL、名称或可识别数据。这有助于我们的开源团队了解使用模式并确定开发优先级。"
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "组件数据",
|
||||||
|
"text": "发送您已配置的组件(及其数量)。不包括URL、名称或任何其他数据。"
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "集成数据",
|
||||||
|
"text": "发送您已配置的集成(及其数量)。不包括URL、名称或任何其他数据。"
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "用户数据",
|
||||||
|
"text": "发送用户数量以及您是否激活了SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "已更新 {when}",
|
"updated": "已更新 {when}",
|
||||||
"search": "搜索 {count} 容器",
|
"search": "搜索 {count} 容器",
|
||||||
"selected": "{totalCount} 容器的 {selectCount}",
|
"selected": "{totalCount} 容器的 {selectCount}",
|
||||||
"footer": "总计 {count} 容器",
|
"footer": "总计 {count} 容器"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4413,8 +4413,20 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "crwdns1764:0crwdne1764:0",
|
"title": "crwdns1764:0crwdne1764:0",
|
||||||
"general": {
|
"general": {
|
||||||
"title": "crwdns5673:0crwdne5673:0",
|
"title": "crwdns1766:0crwdne1766:0",
|
||||||
"text": "crwdns5675:0crwdne5675:0"
|
"text": "crwdns3878:0crwdne3878:0"
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "crwdns1770:0crwdne1770:0",
|
||||||
|
"text": "crwdns1772:0crwdne1772:0"
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "crwdns1774:0crwdne1774:0",
|
||||||
|
"text": "crwdns1776:0crwdne1776:0"
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "crwdns1778:0crwdne1778:0",
|
||||||
|
"text": "crwdns1780:0crwdne1780:0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "crwdns1894:0{when}crwdne1894:0",
|
"updated": "crwdns1894:0{when}crwdne1894:0",
|
||||||
"search": "crwdns3432:0{count}crwdne3432:0",
|
"search": "crwdns3432:0{count}crwdne3432:0",
|
||||||
"selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0",
|
"selected": "crwdns1898:0{selectCount}crwdnd1898:0{totalCount}crwdne1898:0",
|
||||||
"footer": "crwdns3434:0{count}crwdne3434:0",
|
"footer": "crwdns3434:0{count}crwdne3434:0"
|
||||||
"totalCpu": "crwdns5669:0{cpu}crwdne5669:0",
|
|
||||||
"totalMemory": "crwdns5671:0{memory}crwdne5671:0"
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Uživatelská data",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Indsend anonyme analysedata",
|
"title": "Indsend anonyme analysedata",
|
||||||
"text": "Homarr vil sende anonymiserede analyser ved hjælp af open source-softwaren Umami. Den indsamler aldrig personlige oplysninger og er derfor fuldt ud GDPR & CCPA kompatibel. Vi opfordrer dig til at aktivere analyser, fordi det hjælper vores open source-team til at identificere problemer og prioritere vores backlog."
|
"text": "Homarr vil sende anonymiserede analyser ved hjælp af open source-softwaren Umami. Den indsamler aldrig personlige oplysninger og er derfor fuldt ud GDPR & CCPA kompatibel. Vi opfordrer dig til at aktivere analyser, fordi det hjælper vores open source-team til at identificere problemer og prioritere vores backlog."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Widget data",
|
||||||
|
"text": "Send hvilke widgets (og deres antal) du har konfigureret. Indeholder ikke URL'er, navne eller andre data."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integrationsdata",
|
||||||
|
"text": "Send hvilke integrationer (og deres antal) du har konfigureret. Indeholder ikke URL'er, navne eller andre data."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Brugerdata",
|
||||||
|
"text": "Send mængden af brugere, og om du har aktiveret SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Opdateret {when}",
|
"updated": "Opdateret {when}",
|
||||||
"search": "Søg {count} containere",
|
"search": "Søg {count} containere",
|
||||||
"selected": "{selectCount} af {totalCount} containere valgt",
|
"selected": "{selectCount} af {totalCount} containere valgt",
|
||||||
"footer": "Total {count} containere",
|
"footer": "Total {count} containere"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Anonymisierte Nutzungsdaten senden",
|
"title": "Anonymisierte Nutzungsdaten senden",
|
||||||
"text": "Homarr schickt anonymisierte Nutzungsdaten mit der Open-Source-Software Umami. Es sammelt zu keinem Zeitpunkt personenbezogene Daten und ist daher vollständig DPR & CCPA konform. Wir empfehlen diese Nutzungsdaten zu aktivieren, da es unserem Open-Source-Team hilft Probleme zu identifizieren und unserem Backlog ermöglicht Priorität einzuräumen."
|
"text": "Homarr schickt anonymisierte Nutzungsdaten mit der Open-Source-Software Umami. Es sammelt zu keinem Zeitpunkt personenbezogene Daten und ist daher vollständig DPR & CCPA konform. Wir empfehlen diese Nutzungsdaten zu aktivieren, da es unserem Open-Source-Team hilft Probleme zu identifizieren und unserem Backlog ermöglicht Priorität einzuräumen."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Widget Daten",
|
||||||
|
"text": "Senden Sie die Widgets (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integrationsdaten",
|
||||||
|
"text": "Senden Sie die Intregrationen (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Benutzerdaten",
|
||||||
|
"text": "Senden Sie die Anzahl der Benutzer und ob Sie SSO aktiviert haben"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Aktualisiert {when}",
|
"updated": "Aktualisiert {when}",
|
||||||
"search": "{count} Container durchsuchen",
|
"search": "{count} Container durchsuchen",
|
||||||
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -3374,27 +3374,27 @@
|
|||||||
"pausedCount": "Pausiert"
|
"pausedCount": "Pausiert"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
|
"internalServerError": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audioStats": {
|
"audioStats": {
|
||||||
"name": "Audiostatistiken",
|
"name": "Audiostatistiken",
|
||||||
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"showArtists": {
|
"showArtists": {
|
||||||
"label": "Künstler anzeigen",
|
"label": "Künstler anzeigen",
|
||||||
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
|
"description": ""
|
||||||
},
|
},
|
||||||
"showAlbums": {
|
"showAlbums": {
|
||||||
"label": "Alben anzeigen",
|
"label": "",
|
||||||
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
||||||
},
|
},
|
||||||
"showSongs": {
|
"showSongs": {
|
||||||
"label": "Songs anzeigen",
|
"label": "Songs anzeigen",
|
||||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
"description": ""
|
||||||
},
|
},
|
||||||
"showNowPlaying": {
|
"showNowPlaying": {
|
||||||
"label": "\"Sie hören\" anzeigen",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
},
|
||||||
"showNowPlayingList": {
|
"showNowPlayingList": {
|
||||||
@@ -3720,7 +3720,7 @@
|
|||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"title": "CPU Auslastung",
|
"title": "CPU-Auslastung",
|
||||||
"subtitle": "",
|
"subtitle": "",
|
||||||
"series": "CPU"
|
"series": "CPU"
|
||||||
},
|
},
|
||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Anonymisierte Nutzungsdaten senden",
|
"title": "Anonymisierte Nutzungsdaten senden",
|
||||||
"text": "Homarr sendet anonymisierte Analysen mit der Open-Source-Software Umami. Wenn aktiviert, sammeln wir die Homarr-Version, den Datenbanktyp (MySQL/PostgreSQL/SQLite), den Docker/Kubernetes Status, die Authentifizierungsmethoden und die Anzahl der einzelnen Features. Alle Daten sind anonym und aggregiert - es werden keine persönlichen Informationen, URLs, Namen oder identifizierbare Daten gesammelt. Dies hilft unserem Open-Source-Team, Nutzungsmuster zu verstehen und der Entwicklung Priorität einzuräumen."
|
"text": "Homarr sendet anonymisierte Analysen mit der Open-Source-Software Umami. Wenn aktiviert, sammeln wir die Homarr-Version, den Datenbanktyp (MySQL/PostgreSQL/SQLite), den Docker/Kubernetes Status, die Authentifizierungsmethoden und die Anzahl der einzelnen Features. Alle Daten sind anonym und aggregiert - es werden keine persönlichen Informationen, URLs, Namen oder identifizierbare Daten gesammelt. Dies hilft unserem Open-Source-Team, Nutzungsmuster zu verstehen und der Entwicklung Priorität einzuräumen."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Widget Daten",
|
||||||
|
"text": "Senden Sie die Widgets (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integrationsdaten",
|
||||||
|
"text": "Senden Sie die Intregrationen (und deren Anzahl) die Sie konfiguriert haben. Enthält keine URLs, Namen oder andere Daten."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Benutzerdaten",
|
||||||
|
"text": "Senden Sie die Anzahl der Benutzer und ob Sie SSO aktiviert haben"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Aktualisiert {when}",
|
"updated": "Aktualisiert {when}",
|
||||||
"search": "{count} Container durchsuchen",
|
"search": "{count} Container durchsuchen",
|
||||||
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
"selected": "{selectCount} von {totalCount} ausgewählten Containern",
|
||||||
"footer": "Insgesamt {count} Container",
|
"footer": "Insgesamt {count} Container"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"label": "Password",
|
"label": "Password",
|
||||||
"suggestionsTitle": "Password suggestions",
|
"suggestionsTitle": "",
|
||||||
"suggestion": {
|
"suggestion": {
|
||||||
"length": "Includes a minimum of 8 characters",
|
"length": "Includes a minimum of 8 characters",
|
||||||
"lowercase": "Includes a lower-case letter",
|
"lowercase": "Includes a lower-case letter",
|
||||||
@@ -687,28 +687,28 @@
|
|||||||
"customWidget": {
|
"customWidget": {
|
||||||
"page": {
|
"page": {
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Custom Widgets",
|
"title": "",
|
||||||
"noResults": "No custom widgets yet. Create your first one!",
|
"noResults": "",
|
||||||
"loading": "Loading..."
|
"loading": ""
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create Custom Widget",
|
"title": "",
|
||||||
"subtitle": "Define an API URL, authentication, and how to display the response data."
|
"subtitle": ""
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit Custom Widget"
|
"title": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"create": "Create",
|
"create": "",
|
||||||
"import": "Import",
|
"import": "",
|
||||||
"save": "Save",
|
"save": "",
|
||||||
"delete": "Delete Custom Widget",
|
"delete": "",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
"deleteConfirm": "",
|
||||||
"export": "Export",
|
"export": "",
|
||||||
"addMapping": "Add Mapping",
|
"addMapping": "",
|
||||||
"addColumn": "Add Column",
|
"addColumn": "",
|
||||||
"edit": "Edit",
|
"edit": "",
|
||||||
"removeMapping": "",
|
"removeMapping": "",
|
||||||
"removeColumn": "",
|
"removeColumn": "",
|
||||||
"addItem": "",
|
"addItem": "",
|
||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -5599,71 +5609,71 @@
|
|||||||
"label": "",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your colour scheme"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locale": {
|
"locale": {
|
||||||
"label": "Language",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your preferred language"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultSearchEngineId": {
|
"defaultSearchEngineId": {
|
||||||
"label": "Default search engine",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your default search engine"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openSearchInNewTab": {
|
"openSearchInNewTab": {
|
||||||
"label": "Open search results in new tab",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Open search results in new tab"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ddgBangs": {
|
"ddgBangs": {
|
||||||
"label": "Enable DuckDuckGo bangs",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Enable DuckDuckGo bangs"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"firstDayOfWeek": {
|
"firstDayOfWeek": {
|
||||||
"label": "First day of the week",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select the first day of the week"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"homeBoardId": {
|
"homeBoardId": {
|
||||||
"label": "Home board",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your home board"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mobileHomeBoardId": {
|
"mobileHomeBoardId": {
|
||||||
"label": "Mobile home board",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Select your mobile home board"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pingIconsEnabled": {
|
"pingIconsEnabled": {
|
||||||
"label": "Use icons for pings",
|
"label": "",
|
||||||
"children": {
|
"children": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "Use icons for pings"
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1412,10 +1412,7 @@
|
|||||||
"here": "here",
|
"here": "here",
|
||||||
"iconPicker": {
|
"iconPicker": {
|
||||||
"label": "Icon URL",
|
"label": "Icon URL",
|
||||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
|
||||||
"headerLoading": "Type to search icons…",
|
|
||||||
"noResults": "No icons match \"{search}\".",
|
|
||||||
"error": "Couldn't load icons."
|
|
||||||
},
|
},
|
||||||
"colorScheme": {
|
"colorScheme": {
|
||||||
"options": {
|
"options": {
|
||||||
@@ -1467,7 +1464,6 @@
|
|||||||
"switchToDarkMode": "Switch to dark mode",
|
"switchToDarkMode": "Switch to dark mode",
|
||||||
"switchToLightMode": "Switch to light mode",
|
"switchToLightMode": "Switch to light mode",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"docker": "Docker",
|
|
||||||
"preferences": "Your preferences",
|
"preferences": "Your preferences",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -3643,9 +3639,6 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||||
},
|
},
|
||||||
"empty": {
|
|
||||||
"noSystems": "No systems found"
|
|
||||||
},
|
|
||||||
"metric": {
|
"metric": {
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
@@ -3719,9 +3712,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectSystem": "Select system",
|
"selectSystem": "Select system",
|
||||||
"empty": {
|
|
||||||
"noSystems": "No systems found"
|
|
||||||
},
|
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||||
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
||||||
@@ -4296,7 +4286,7 @@
|
|||||||
"notStarted": "Not started"
|
"notStarted": "Not started"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"dashboard": "Dashboard"
|
"dashboard": "Dashboard"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Enviar estadísticas anónimas",
|
"title": "Enviar estadísticas anónimas",
|
||||||
"text": "Homarr enviará estadísticas anónimas usando el software de código abierto Umami. Nunca recopila información personal y por lo tanto cumple plenamente con GDPR y CCPA. Te animamos a habilitar esta característica ya que ayuda a nuestro equipo de código abierto a identificar problemas y priorizar su resolución."
|
"text": "Homarr enviará estadísticas anónimas usando el software de código abierto Umami. Nunca recopila información personal y por lo tanto cumple plenamente con GDPR y CCPA. Te animamos a habilitar esta característica ya que ayuda a nuestro equipo de código abierto a identificar problemas y priorizar su resolución."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Datos del widget",
|
||||||
|
"text": "Enviar qué widgets (y su cantidad) has configurado. No incluye URLs, nombres ni ningún otro dato."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Datos de integración",
|
||||||
|
"text": "Enviar qué integraciones (y su cantidad) has configurado. No incluye URLs, nombres ni ningún otro dato."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Datos de usuarios",
|
||||||
|
"text": "Enviar la cantidad de usuarios y si has activado SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Actualizado {when}",
|
"updated": "Actualizado {when}",
|
||||||
"search": "Buscar {count} contenedores",
|
"search": "Buscar {count} contenedores",
|
||||||
"selected": "{selectCount} de {totalCount} contenedores seleccionados",
|
"selected": "{selectCount} de {totalCount} contenedores seleccionados",
|
||||||
"footer": "{count} contenedores en total",
|
"footer": "{count} contenedores en total"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Lähetä anonyymejä käyttötietoja",
|
"title": "Lähetä anonyymejä käyttötietoja",
|
||||||
"text": "Homarr lähettää anonymisoitua analytiikkaa avoimen lähdekoodin Umami‑järjestelmän avulla. Kun analytiikka on käytössä, keräämme seuraavat tiedot: Homarrin version, tietokannan tyypin (MySQL/PostgreSQL/SQLite), Docker‑/Kubernetes‑tilan, käytössä olevat tunnistautumismenetelmät sekä eri ominaisuuksien lukumäärät. Kaikki tiedot on anonymisoitu ja koottu tilastomuotoon – mitään henkilötietoja, URL‑osoitteita, nimiä tai muita tunnistettavia tietoja ei koskaan kerätä. Tämä auttaa avoimen lähdekoodin tiimiämme ymmärtämään käyttötapoja ja priorisoimaan kehitystä."
|
"text": "Homarr lähettää anonymisoitua analytiikkaa avoimen lähdekoodin Umami‑järjestelmän avulla. Kun analytiikka on käytössä, keräämme seuraavat tiedot: Homarrin version, tietokannan tyypin (MySQL/PostgreSQL/SQLite), Docker‑/Kubernetes‑tilan, käytössä olevat tunnistautumismenetelmät sekä eri ominaisuuksien lukumäärät. Kaikki tiedot on anonymisoitu ja koottu tilastomuotoon – mitään henkilötietoja, URL‑osoitteita, nimiä tai muita tunnistettavia tietoja ei koskaan kerätä. Tämä auttaa avoimen lähdekoodin tiimiämme ymmärtämään käyttötapoja ja priorisoimaan kehitystä."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integraatiotiedot",
|
||||||
|
"text": "Lähetä mitä ja miten monta integraatiota olet määrittänyt. Ei sisällä URL-osoitteita, nimiä tai mitään muita tietoja."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Päivitetty {when}",
|
"updated": "Päivitetty {when}",
|
||||||
"search": "Suodata {count} konttia...",
|
"search": "Suodata {count} konttia...",
|
||||||
"selected": "{selectCount}/{totalCount} kontista valittu",
|
"selected": "{selectCount}/{totalCount} kontista valittu",
|
||||||
"footer": "Yhteensä {count} konttia",
|
"footer": "Yhteensä {count} konttia"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Envoyer des statistiques anonymes",
|
"title": "Envoyer des statistiques anonymes",
|
||||||
"text": "Homarr enverra des statistiques anonymes en utilisant le logiciel open source Umami. Il ne recueille jamais aucune information personnelle et est donc entièrement conforme au RGPD & CCPA. Nous vous encourageons à activer les statistiques parce qu'elles aident notre équipe open source à identifier les problèmes et à prioriser notre backlog."
|
"text": "Homarr enverra des statistiques anonymes en utilisant le logiciel open source Umami. Il ne recueille jamais aucune information personnelle et est donc entièrement conforme au RGPD & CCPA. Nous vous encourageons à activer les statistiques parce qu'elles aident notre équipe open source à identifier les problèmes et à prioriser notre backlog."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Données du widget",
|
||||||
|
"text": "Envoie quels widgets (et leur quantité) vous avez configurés. N'inclut pas les URLs, les noms ou toute autre donnée."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Données d'intégration",
|
||||||
|
"text": "Envoie quelles intégrations (et leur quantité) vous avez configurées. N'inclut pas les URLs, les noms ou toute autre donnée."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Données utilisateur",
|
||||||
|
"text": "Envoie le nombre d'utilisateurs et si vous avez activé le SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Mis à jour {when}",
|
"updated": "Mis à jour {when}",
|
||||||
"search": "Rechercher dans {count} conteneurs",
|
"search": "Rechercher dans {count} conteneurs",
|
||||||
"selected": "{selectCount} sur {totalCount} conteneurs sélectionnés",
|
"selected": "{selectCount} sur {totalCount} conteneurs sélectionnés",
|
||||||
"footer": "Total des conteneurs {count}",
|
"footer": "Total des conteneurs {count}"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "שליחת ניתוח נתונים אנונימי",
|
"title": "שליחת ניתוח נתונים אנונימי",
|
||||||
"text": "Homarr ישלח ניתוחים אנונימיים באמצעות התוכנה בקוד פתוח Umami. הוא לעולם אינו אוסף מידע אישי ולכן תואם לחלוטין לדרישות ה-GDPR וה-CCPA. אנו מעודדים אותך להפעיל ניתוחים, כיוון שזה מסייע לצוות הקוד הפתוח שלנו לזהות בעיות ולהעניק עדיפות למשימות שברשימת העדיפויות שלנו."
|
"text": "Homarr ישלח ניתוחים אנונימיים באמצעות התוכנה בקוד פתוח Umami. הוא לעולם אינו אוסף מידע אישי ולכן תואם לחלוטין לדרישות ה-GDPR וה-CCPA. אנו מעודדים אותך להפעיל ניתוחים, כיוון שזה מסייע לצוות הקוד הפתוח שלנו לזהות בעיות ולהעניק עדיפות למשימות שברשימת העדיפויות שלנו."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "נתוני ווידג׳ט",
|
||||||
|
"text": "שלח אילו ווידג'טים (וכמותם) הוגדרו. אינו כולל כתובות רשת, שמות או כל מידע נוסף אחר."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "נתוני אינטגרציה",
|
||||||
|
"text": "שלח אילו אינטגרציות (וכמותם) הוגדרו. לא כולל כתובות רשת, שמות או כל מידע נוסף אחר."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "נתוני משתמשים",
|
||||||
|
"text": "שלח את כמות המשתמשים והאם הופעל SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "עודכן ב- {when}",
|
"updated": "עודכן ב- {when}",
|
||||||
"search": "חפש {count} קונטיינרים",
|
"search": "חפש {count} קונטיינרים",
|
||||||
"selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו",
|
"selected": "{selectCount} מ- {totalCount} קונטיינרים נבחרו",
|
||||||
"footer": "סה״כ {count} קונטיינרים",
|
"footer": "סה״כ {count} קונטיינרים"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": "A Homarr az Umami nevű nyílt forráskódú szoftver segítségével névtelen elemzési adatokat küld. Ha ez az opció engedélyezve van, a következő adatokat gyűjtjük: a Homarr verziója, az adatbázis típusa (MySQL/PostgreSQL/SQLite), a Docker/Kubernetes állapota, az engedélyezett hitelesítési módszerek, valamint az egyes funkciók száma. Minden adat névtelen és összesített formában kerül rögzítésre – személyes adatokat, URL-eket, neveket vagy bármilyen azonosítható adatot soha nem gyűjtünk. Ez segít nyílt forráskódú fejlesztői csapatunknak a használati szokások megértésében és a fejlesztési feladatok fontossági sorrendjének meghatározásában."
|
"text": "A Homarr az Umami nevű nyílt forráskódú szoftver segítségével névtelen elemzési adatokat küld. Ha ez az opció engedélyezve van, a következő adatokat gyűjtjük: a Homarr verziója, az adatbázis típusa (MySQL/PostgreSQL/SQLite), a Docker/Kubernetes állapota, az engedélyezett hitelesítési módszerek, valamint az egyes funkciók száma. Minden adat névtelen és összesített formában kerül rögzítésre – személyes adatokat, URL-eket, neveket vagy bármilyen azonosítható adatot soha nem gyűjtünk. Ez segít nyílt forráskódú fejlesztői csapatunknak a használati szokások megértésében és a fejlesztési feladatok fontossági sorrendjének meghatározásában."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Invia analytics anonimi",
|
"title": "Invia analytics anonimi",
|
||||||
"text": "Homarr invierà analisi anonime utilizzando il software open source Umami. Non raccoglie mai informazioni personali ed è quindi pienamente conforme al GDPR e al CCPA. Ti invitiamo ad abilitare l'analisi perché aiuta il nostro team open source a identificare i problemi e a dare priorità al nostro arretrato."
|
"text": "Homarr invierà analisi anonime utilizzando il software open source Umami. Non raccoglie mai informazioni personali ed è quindi pienamente conforme al GDPR e al CCPA. Ti invitiamo ad abilitare l'analisi perché aiuta il nostro team open source a identificare i problemi e a dare priorità al nostro arretrato."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Dati widget",
|
||||||
|
"text": "Invia quali widget (e la loro quantità) hai configurato. Non include URL, nomi o altri dati."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Dati di integrazione",
|
||||||
|
"text": "Invia quali integrazioni (e la loro quantità) hai configurato. Non include URL, nomi o altri dati."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Dati utenti",
|
||||||
|
"text": "Invia il numero di utenti e se hai attivato SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Aggiornato {when}",
|
"updated": "Aggiornato {when}",
|
||||||
"search": "Cerca {count} container",
|
"search": "Cerca {count} container",
|
||||||
"selected": "{selectCount} di {totalCount} containers selezionati",
|
"selected": "{selectCount} di {totalCount} containers selezionati",
|
||||||
"footer": "Totale container {count}",
|
"footer": "Totale container {count}"
|
||||||
"totalCpu": "CPU Totale: {cpu}%",
|
|
||||||
"totalMemory": "Mem Totale: {memory}"
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "匿名の解析を送信",
|
"title": "匿名の解析を送信",
|
||||||
"text": "Homarrは、オープンソースソフトウェアUmamiを使用して匿名化された分析を送信します。 有効にすると、以下が収集されます。Homarrバージョン、データベースタイプ(MySQL/PostgreSQL/SQLite)、Docker/Kubernetesステータス、認証方法、各機能の数。 すべてのデータは匿名で集計されます。個人情報、URL、名前、または識別可能なデータは収集されません。 これにより、オープンソースチームは利用パターンを理解し、開発に優先順位を付けることができます。"
|
"text": "Homarrは、オープンソースソフトウェアUmamiを使用して匿名化された分析を送信します。 有効にすると、以下が収集されます。Homarrバージョン、データベースタイプ(MySQL/PostgreSQL/SQLite)、Docker/Kubernetesステータス、認証方法、各機能の数。 すべてのデータは匿名で集計されます。個人情報、URL、名前、または識別可能なデータは収集されません。 これにより、オープンソースチームは利用パターンを理解し、開発に優先順位を付けることができます。"
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "ウィジェットのデータ",
|
||||||
|
"text": "あなたが設定したウィジェット(およびその数量)を送信します。URL・名前・その他のデータは含まれません。"
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "連携情報",
|
||||||
|
"text": "あなたが設定した連携機能(およびその数量)を送信します。URL・名前・その他のデータは含まれません。"
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "ユーザー情報",
|
||||||
|
"text": "SSOをアクティベートしたユーザーの数を送信します"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "{when} に更新しました",
|
"updated": "{when} に更新しました",
|
||||||
"search": "{count} 個のコンテナを検索",
|
"search": "{count} 個のコンテナを検索",
|
||||||
"selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています",
|
"selected": "{totalCount} 個のうち、{selectCount} 個のコンテナが選択されています",
|
||||||
"footer": "合計 {count} 個のコンテナ",
|
"footer": "合計 {count} 個のコンテナ"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Anonieme analyses sturen",
|
"title": "Anonieme analyses sturen",
|
||||||
"text": "Homarr verstuurt geanonimiseerde analyses met behulp van de open source software Umami. Het verzamelt nooit persoonlijke informatie en is daarom volledig GDPR & CCPA compliant. We moedigen je aan om analytics in te schakelen omdat het ons open source team helpt bij het identificeren van problemen en het prioriteren van onze backlog."
|
"text": "Homarr verstuurt geanonimiseerde analyses met behulp van de open source software Umami. Het verzamelt nooit persoonlijke informatie en is daarom volledig GDPR & CCPA compliant. We moedigen je aan om analytics in te schakelen omdat het ons open source team helpt bij het identificeren van problemen en het prioriteren van onze backlog."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Widgetgegevens",
|
||||||
|
"text": "Stuur welke widgets (en hun aantal) je hebt geconfigureerd. Bevat geen URL's, namen of andere gegevens."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integratiegegevens",
|
||||||
|
"text": "Stuur welke integraties (en het aantal) je hebt geconfigureerd. Bevat geen URL's, namen of andere gegevens."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Gebruikersgegevens",
|
||||||
|
"text": "Stuur het aantal gebruikers en of je SSO hebt geactiveerd"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "{when} bijgewerkt",
|
"updated": "{when} bijgewerkt",
|
||||||
"search": "{count} containers zoeken",
|
"search": "{count} containers zoeken",
|
||||||
"selected": "{selectCount} van {totalCount} containers geselecteerd",
|
"selected": "{selectCount} van {totalCount} containers geselecteerd",
|
||||||
"footer": "Totaal {count} containers",
|
"footer": "Totaal {count} containers"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Send anonym brukerdata",
|
"title": "Send anonym brukerdata",
|
||||||
"text": "Homarr vil sende anonymiserte analyser ved bruk av den åpne programvaren Umami. Når dette er aktivert, samler vi inn: Homarr-versjon, databastype (MySQL/PostgreSQL/SQLite), Docker/Kubernetes-status, aktiverte autentiseringsmetoder, og antallet av hver funksjon. Alle data er anonyme og aggregerte – ingen personlig informasjon, URL-er, navn eller identifiserbare data samles inn. Dette hjelper vårt open source-team med å forstå bruks‑mønstre og prioritere utvikling."
|
"text": "Homarr vil sende anonymiserte analyser ved bruk av den åpne programvaren Umami. Når dette er aktivert, samler vi inn: Homarr-versjon, databastype (MySQL/PostgreSQL/SQLite), Docker/Kubernetes-status, aktiverte autentiseringsmetoder, og antallet av hver funksjon. Alle data er anonyme og aggregerte – ingen personlig informasjon, URL-er, navn eller identifiserbare data samles inn. Dette hjelper vårt open source-team med å forstå bruks‑mønstre og prioritere utvikling."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Widget data",
|
||||||
|
"text": "Send hvilke widgeter (og deres antall) du har konfigurert. Inkluderer ikke nettadresser, navn eller andre data."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integrasjonsdata",
|
||||||
|
"text": "Send hvilke integrasjoner (og deres antall) du har konfigurert. Inkluderer ikke nettadresser, navn eller andre data."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Brukeres data",
|
||||||
|
"text": "Send antall brukere og om du har aktivert SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Oppdatert {when}",
|
"updated": "Oppdatert {when}",
|
||||||
"search": "Søk {count} containere",
|
"search": "Søk {count} containere",
|
||||||
"selected": "{selectCount} av {totalCount} containere valgt",
|
"selected": "{selectCount} av {totalCount} containere valgt",
|
||||||
"footer": "Totalt {count} containere",
|
"footer": "Totalt {count} containere"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Wysyłaj anonimowe dane analityczne",
|
"title": "Wysyłaj anonimowe dane analityczne",
|
||||||
"text": "Homarr będzie wysyłał anonimowe dane analityczne przy użyciu oprogramowania open source Umami. Nigdy nie zbiera żadnych danych osobowych, dlatego jest w pełni zgodny z RODO i CCPA. Zachęcamy do włączenia analityki, ponieważ pomaga to naszemu zespołowi open source w identyfikowaniu problemów i ustalaniu priorytetów w backlogu."
|
"text": "Homarr będzie wysyłał anonimowe dane analityczne przy użyciu oprogramowania open source Umami. Nigdy nie zbiera żadnych danych osobowych, dlatego jest w pełni zgodny z RODO i CCPA. Zachęcamy do włączenia analityki, ponieważ pomaga to naszemu zespołowi open source w identyfikowaniu problemów i ustalaniu priorytetów w backlogu."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Dane widżetu",
|
||||||
|
"text": "Wyślij informacje o tym, które widżety (i w jakiej ilości) masz skonfigurowane. Nie obejmuje to adresów URL, nazw ani żadnych innych danych."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Informacje o integracjach",
|
||||||
|
"text": "Wyślij informacje o tym, które integracje (i w jakiej ilości) masz skonfigurowane. Nie obejmuje to adresów URL, nazw ani żadnych innych danych."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Dane użytkowników",
|
||||||
|
"text": "Wyślij liczbę użytkowników oraz informację, czy SSO jest aktywne."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Zaktualizowano {when}",
|
"updated": "Zaktualizowano {when}",
|
||||||
"search": "Szukaj wśród {count} kontenerów",
|
"search": "Szukaj wśród {count} kontenerów",
|
||||||
"selected": "Wybrano {selectCount} z {totalCount} kontenerów",
|
"selected": "Wybrano {selectCount} z {totalCount} kontenerów",
|
||||||
"footer": "Łącznie {count} kontenerów",
|
"footer": "Łącznie {count} kontenerów"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Enviar análises anônimas",
|
"title": "Enviar análises anônimas",
|
||||||
"text": "O Homarr enviará análises anonimizadas usando o software de código aberto Umami. Quando habilitado, coletamos: versão do Homarr, tipo de banco de dados (MySQL/PostgreSQL/SQLite), status Docker/Kubernetes, métodos de autenticação habilitados e a quantidade de cada recurso. Todos os dados são anônimos e agregados - nenhuma informação pessoal, URLs, nomes ou dados identificáveis são coletados. Isso ajuda nossa equipe de código aberto a entender padrões de uso e priorizar o desenvolvimento."
|
"text": "O Homarr enviará análises anonimizadas usando o software de código aberto Umami. Quando habilitado, coletamos: versão do Homarr, tipo de banco de dados (MySQL/PostgreSQL/SQLite), status Docker/Kubernetes, métodos de autenticação habilitados e a quantidade de cada recurso. Todos os dados são anônimos e agregados - nenhuma informação pessoal, URLs, nomes ou dados identificáveis são coletados. Isso ajuda nossa equipe de código aberto a entender padrões de uso e priorizar o desenvolvimento."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Dados de widgets",
|
||||||
|
"text": "Envia quais widgets (e suas quantidades) você configurou. Não inclui URLs, nomes ou quaisquer outros dados."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Dados de integrações",
|
||||||
|
"text": "Envia quais integrações (e suas quantidades) você configurou. Não inclui URLs, nomes ou quaisquer outros dados."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Dados dos usuários",
|
||||||
|
"text": "Envia a quantidade de usuários e se você ativou SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Atualizado em {when}",
|
"updated": "Atualizado em {when}",
|
||||||
"search": "Buscar {count} contêineres",
|
"search": "Buscar {count} contêineres",
|
||||||
"selected": "{selectCount} de {totalCount} contêineres selecionados",
|
"selected": "{selectCount} de {totalCount} contêineres selecionados",
|
||||||
"footer": "Total de contêineres: {count}",
|
"footer": "Total de contêineres: {count}"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Отправлять анонимную аналитику",
|
"title": "Отправлять анонимную аналитику",
|
||||||
"text": "Homarr отправляет обезличенную аналитику с помощью открытого ПО Umami. При включении собираются: версия Homarr, тип базы данных (MySQL/PostgreSQL/SQLite), статус Docker/Kubernetes, включённые методы аутентификации и количество используемых функций. Все данные анонимны и агрегированы - личная информация, URL-адреса, имена и другие идентифицирующие данные никогда не собираются. Это помогает нашей команде понимать типичные сценарии использования и определять приоритеты в разработке."
|
"text": "Homarr отправляет обезличенную аналитику с помощью открытого ПО Umami. При включении собираются: версия Homarr, тип базы данных (MySQL/PostgreSQL/SQLite), статус Docker/Kubernetes, включённые методы аутентификации и количество используемых функций. Все данные анонимны и агрегированы - личная информация, URL-адреса, имена и другие идентифицирующие данные никогда не собираются. Это помогает нашей команде понимать типичные сценарии использования и определять приоритеты в разработке."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Данные о виджетах",
|
||||||
|
"text": "Отправлять информацию о настроенных виджетах (и их количестве). Не включает URL-адреса, названия или другие данные."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Данные об интеграциях",
|
||||||
|
"text": "Отправлять информацию о настроенных интеграциях (и их количестве). Не включает URL-адреса, названия или другие данные."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Данные о пользователях",
|
||||||
|
"text": "Отправлять количество пользователей и информацию об использовании единого входа"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Обновлено {when}",
|
"updated": "Обновлено {when}",
|
||||||
"search": "Поиск среди {count} контейнеров",
|
"search": "Поиск среди {count} контейнеров",
|
||||||
"selected": "Выбрано {selectCount} из {totalCount} контейнеров",
|
"selected": "Выбрано {selectCount} из {totalCount} контейнеров",
|
||||||
"footer": "Всего {count} контейнеров",
|
"footer": "Всего {count} контейнеров"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Odoslať anonymné analýzy",
|
"title": "Odoslať anonymné analýzy",
|
||||||
"text": "Homarr bude posielať anonymizované analýzy pomocou open source softvéru Umami. Nikdy nezhromažďuje žiadne osobné údaje, a preto je plne v súlade s GDPR a CCPA. Odporúčame vám povoliť analýzu, pretože pomáha nášmu tímu s otvoreným zdrojom identifikovať problémy a uprednostňovať naše nevybavené veci."
|
"text": "Homarr bude posielať anonymizované analýzy pomocou open source softvéru Umami. Nikdy nezhromažďuje žiadne osobné údaje, a preto je plne v súlade s GDPR a CCPA. Odporúčame vám povoliť analýzu, pretože pomáha nášmu tímu s otvoreným zdrojom identifikovať problémy a uprednostňovať naše nevybavené veci."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Údaje widgetu",
|
||||||
|
"text": "Pošlite, ktoré widgety (a ich množstvo) ste nakonfigurovali. Nezahŕňa adresy URL, mená ani iné údaje."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Integračné údaje",
|
||||||
|
"text": "Pošlite, ktoré integrácie (a ich množstvo) ste nakonfigurovali. Nezahŕňa adresy URL, mená ani iné údaje."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Údaje o používateľoch",
|
||||||
|
"text": "Pošlite počet používateľov a či ste aktivovali jednotné prihlásenie"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Aktualizované {when}",
|
"updated": "Aktualizované {when}",
|
||||||
"search": "Vyhľadajte {count} kontajnery",
|
"search": "Vyhľadajte {count} kontajnery",
|
||||||
"selected": "{selectCount} z {totalCount} vybratých kontajnerov",
|
"selected": "{selectCount} z {totalCount} vybratých kontajnerov",
|
||||||
"footer": "Celkový počet kontajnerov: {count}",
|
"footer": "Celkový počet kontajnerov: {count}"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Uppdaterad {when}",
|
"updated": "Uppdaterad {when}",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Anonim analizler gönder",
|
"title": "Anonim analizler gönder",
|
||||||
"text": "Homarr, açık kaynaklı yazılım Umami'yi kullanarak anonimleştirilmiş analizler gönderecektir. Hiçbir zaman kişisel bilgi toplamaz ve bu nedenle tamamen GDPR ve CCPA uyumludur. Analizleri etkinleştirmenizi öneririz çünkü, açık kaynak yazılım ekibimizin sorunları daha hızlı belirlemesine ve birikmiş işlerimizi önceliklendirmesine yardımcı olmaktadır."
|
"text": "Homarr, açık kaynaklı yazılım Umami'yi kullanarak anonimleştirilmiş analizler gönderecektir. Hiçbir zaman kişisel bilgi toplamaz ve bu nedenle tamamen GDPR ve CCPA uyumludur. Analizleri etkinleştirmenizi öneririz çünkü, açık kaynak yazılım ekibimizin sorunları daha hızlı belirlemesine ve birikmiş işlerimizi önceliklendirmesine yardımcı olmaktadır."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Araç verileri",
|
||||||
|
"text": "Hangi araçları (ve miktarlarını) yapılandırdığınızı gönderin. URL'leri, adları veya başka herhangi bir veri içermez."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Entegrasyon verileri",
|
||||||
|
"text": "Entegrasyon (ve sayıları) yapılandırma verilerini gönder. URL'leri, alan adlarını veya başka herhangi bir veriyi içermez."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Kullanıcı verileri",
|
||||||
|
"text": "Kullanıcı sayısını ve SSO etkinleştirme verilerini gönder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "{when} Güncellendi",
|
"updated": "{when} Güncellendi",
|
||||||
"search": "{count} konteyner içinde ara",
|
"search": "{count} konteyner içinde ara",
|
||||||
"selected": "{selectCount} / {totalCount} konteyner seçildi",
|
"selected": "{selectCount} / {totalCount} konteyner seçildi",
|
||||||
"footer": "Toplam {count} konteyner",
|
"footer": "Toplam {count} konteyner"
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -687,7 +687,7 @@
|
|||||||
"customWidget": {
|
"customWidget": {
|
||||||
"page": {
|
"page": {
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Власні віджети",
|
"title": "",
|
||||||
"noResults": "",
|
"noResults": "",
|
||||||
"loading": ""
|
"loading": ""
|
||||||
},
|
},
|
||||||
@@ -874,7 +874,7 @@
|
|||||||
"exportError": "",
|
"exportError": "",
|
||||||
"duplicated": "",
|
"duplicated": "",
|
||||||
"duplicateError": "",
|
"duplicateError": "",
|
||||||
"toggleError": "Не вдалося змінити стан віджету.",
|
"toggleError": "",
|
||||||
"deleteError": "",
|
"deleteError": "",
|
||||||
"previewError": "",
|
"previewError": "",
|
||||||
"executed": "",
|
"executed": "",
|
||||||
@@ -1021,7 +1021,7 @@
|
|||||||
"mediaMonitoring": "",
|
"mediaMonitoring": "",
|
||||||
"speedtest": "",
|
"speedtest": "",
|
||||||
"analytics": "",
|
"analytics": "",
|
||||||
"mediaLibrary": "Медіа бібліотека",
|
"mediaLibrary": "",
|
||||||
"uptimeMonitoring": ""
|
"uptimeMonitoring": ""
|
||||||
},
|
},
|
||||||
"testConnection": {
|
"testConnection": {
|
||||||
@@ -1139,7 +1139,7 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"connection": {
|
"connection": {
|
||||||
"hostUnreachable": "",
|
"hostUnreachable": "",
|
||||||
"networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
|
"networkUnreachable": "",
|
||||||
"refused": "",
|
"refused": "",
|
||||||
"reset": ""
|
"reset": ""
|
||||||
},
|
},
|
||||||
@@ -1328,7 +1328,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"paperlessNgx": {
|
"paperlessNgx": {
|
||||||
"name": "Безпаперовий ngx",
|
"name": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1679,9 +1679,9 @@
|
|||||||
"label": "",
|
"label": "",
|
||||||
"option": {
|
"option": {
|
||||||
"row": "",
|
"row": "",
|
||||||
"row-reverse": "Горизонтально (зворотнє)",
|
"row-reverse": "",
|
||||||
"column": "",
|
"column": "",
|
||||||
"column-reverse": "Вертикальна (перевернута)"
|
"column-reverse": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"descriptionDisplayMode": {
|
"descriptionDisplayMode": {
|
||||||
@@ -1690,7 +1690,7 @@
|
|||||||
"option": {
|
"option": {
|
||||||
"normal": "",
|
"normal": "",
|
||||||
"tooltip": "",
|
"tooltip": "",
|
||||||
"hidden": "Приховати"
|
"hidden": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1703,7 +1703,7 @@
|
|||||||
},
|
},
|
||||||
"bookmarks": {
|
"bookmarks": {
|
||||||
"name": "Закладки",
|
"name": "Закладки",
|
||||||
"description": "Показує декілька посилань додатка",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"title": {
|
"title": {
|
||||||
"label": "Назва"
|
"label": "Назва"
|
||||||
@@ -1747,8 +1747,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"archiveTeamWarrior": {
|
"archiveTeamWarrior": {
|
||||||
"name": "АрхівTeam Warrior",
|
"name": "",
|
||||||
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
|
"description": "",
|
||||||
"option": {
|
"option": {
|
||||||
"showBroadcastMessage": {
|
"showBroadcastMessage": {
|
||||||
"label": ""
|
"label": ""
|
||||||
@@ -1778,7 +1778,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Не вдалося отримати резюме DNS Hole",
|
"internalServerError": "",
|
||||||
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1808,7 +1808,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"showToggleAllButtons": {
|
"showToggleAllButtons": {
|
||||||
"label": "Показати всі кнопки"
|
"label": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "Надсилати анонімну аналітику",
|
"title": "Надсилати анонімну аналітику",
|
||||||
"text": "Homarr буде відправляти анонімну аналітику з використанням програмного забезпечення з відкритим вихідним кодом Umami. Він ніколи не збирає жодної особистої інформації і, таким чином, сумісний з GDPR та CCPA. Ми заохочуємо вас увімкнути аналітику, тому що це допомагає нашій команді визначати проблеми та пріоритетні задачі."
|
"text": "Homarr буде відправляти анонімну аналітику з використанням програмного забезпечення з відкритим вихідним кодом Umami. Він ніколи не збирає жодної особистої інформації і, таким чином, сумісний з GDPR та CCPA. Ми заохочуємо вас увімкнути аналітику, тому що це допомагає нашій команді визначати проблеми та пріоритетні задачі."
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "Дані віджетів",
|
||||||
|
"text": "Надсилати, які віджети (і їх кількість) ви налаштували. Не містить URL, назви, або будь-які інші дані."
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "Дані інтеграції",
|
||||||
|
"text": "Надсилати, які віджети (і їх кількість) ви налаштували. Не містить URL, назви, або будь-які інші дані."
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "Дані користувачів",
|
||||||
|
"text": "Надсилати кількість користувачів і чи активували ви SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "Оновлено {when}",
|
"updated": "Оновлено {when}",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "Вибрано {selectCount} із {totalCount} контейнерів",
|
"selected": "Вибрано {selectCount} із {totalCount} контейнерів",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "",
|
"title": "",
|
||||||
"text": ""
|
"text": ""
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "",
|
||||||
|
"text": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "",
|
"updated": "",
|
||||||
"search": "",
|
"search": "",
|
||||||
"selected": "",
|
"selected": "",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -4415,6 +4415,18 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"title": "發送匿名分析",
|
"title": "發送匿名分析",
|
||||||
"text": "Homarr 使用開源軟體 Umami 發送匿名分析,他從不收集任何個人資料,因此完全符合 GDPR 和 CCPA 要求,我們鼓勵啟用分析,因為可以幫助我們的團隊識別問題,並確認待辦事項的優先級別"
|
"text": "Homarr 使用開源軟體 Umami 發送匿名分析,他從不收集任何個人資料,因此完全符合 GDPR 和 CCPA 要求,我們鼓勵啟用分析,因為可以幫助我們的團隊識別問題,並確認待辦事項的優先級別"
|
||||||
|
},
|
||||||
|
"widgetData": {
|
||||||
|
"title": "小工具數據",
|
||||||
|
"text": "發送已設定的小工具 (及其數量),不包括網址、名稱或任何其他數據"
|
||||||
|
},
|
||||||
|
"integrationData": {
|
||||||
|
"title": "集成數據",
|
||||||
|
"text": "發送已設定的集成 (及其數量),不包括網址、名稱或任何其他數據"
|
||||||
|
},
|
||||||
|
"usersData": {
|
||||||
|
"title": "使用者資料",
|
||||||
|
"text": "發送使用者數量以及是否激活了 SSO"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"crawlingAndIndexing": {
|
"crawlingAndIndexing": {
|
||||||
@@ -4942,9 +4954,7 @@
|
|||||||
"updated": "已更新於 {when}",
|
"updated": "已更新於 {when}",
|
||||||
"search": "搜尋 {count} 容器",
|
"search": "搜尋 {count} 容器",
|
||||||
"selected": "{totalCount} 容器的 {selectCount}",
|
"selected": "{totalCount} 容器的 {selectCount}",
|
||||||
"footer": "",
|
"footer": ""
|
||||||
"totalCpu": "",
|
|
||||||
"totalMemory": ""
|
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
@@ -12,7 +12,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
|
||||||
|
|
||||||
const alertIconMap: Record<string, LucideIcon> = {
|
const alertIconMap: Record<string, LucideIcon> = {
|
||||||
CPU: Cpu,
|
CPU: Cpu,
|
||||||
@@ -41,11 +40,7 @@ export default function BeszelAlertsWidget({
|
|||||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||||
);
|
);
|
||||||
const {
|
const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||||
data: results = [],
|
|
||||||
error: alertsError,
|
|
||||||
isPending,
|
|
||||||
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -98,126 +93,113 @@ export default function BeszelAlertsWidget({
|
|||||||
|
|
||||||
if (alertsError) throw alertsError;
|
if (alertsError) throw alertsError;
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<Center h="100%">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
<Stack gap="sm" p="sm">
|
||||||
<BeszelIntegrationErrorIndicator results={results} />
|
{alerts.length === 0 && (
|
||||||
</Box>
|
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||||
<Stack gap="sm" p="sm">
|
<IconBellOff size={18} />
|
||||||
{alerts.length === 0 && (
|
</ThemeIcon>
|
||||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
<Text size="sm" c="dimmed">
|
||||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
{t("empty")}
|
||||||
<IconBellOff size={18} />
|
</Text>
|
||||||
</ThemeIcon>
|
</Stack>
|
||||||
<Text size="sm" c="dimmed">
|
)}
|
||||||
{t("empty")}
|
|
||||||
|
{triggeredAlerts.length > 0 && (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||||
|
{t("status.triggered")} ({triggeredAlerts.length})
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Group>
|
||||||
)}
|
{triggeredAlerts.map((alert) => (
|
||||||
|
<AlertRow
|
||||||
|
key={alert._key}
|
||||||
|
name={alert.name}
|
||||||
|
value={alert.value}
|
||||||
|
min={alert.min}
|
||||||
|
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||||
|
triggered
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{triggeredAlerts.length > 0 && (
|
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||||
|
|
||||||
|
{okAlerts.length > 0 && (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||||
|
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||||
|
{t("status.ok")} ({okAlerts.length})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{okAlerts.map((alert) => (
|
||||||
|
<AlertRow
|
||||||
|
key={alert._key}
|
||||||
|
name={alert.name}
|
||||||
|
value={alert.value}
|
||||||
|
min={alert.min}
|
||||||
|
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||||
|
triggered={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{options.showHistory && history.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
<IconHistory size={14} opacity={0.5} />
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
|
||||||
{t("status.triggered")} ({triggeredAlerts.length})
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{triggeredAlerts.map((alert) => (
|
|
||||||
<AlertRow
|
|
||||||
key={alert._key}
|
|
||||||
name={alert.name}
|
|
||||||
value={alert.value}
|
|
||||||
min={alert.min}
|
|
||||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
|
||||||
triggered
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
|
||||||
|
|
||||||
{okAlerts.length > 0 && (
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Group gap={6}>
|
|
||||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||||
{t("status.ok")} ({okAlerts.length})
|
{t("history")}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{okAlerts.map((alert) => (
|
{history.map((entry) => {
|
||||||
<AlertRow
|
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||||
key={alert._key}
|
const isResolved = !!entry.resolved;
|
||||||
name={alert.name}
|
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||||
value={alert.value}
|
return (
|
||||||
min={alert.min}
|
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
triggered={false}
|
<Box
|
||||||
/>
|
w={3}
|
||||||
))}
|
h={24}
|
||||||
</Stack>
|
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||||
)}
|
bg={isResolved ? "green.6" : "red.6"}
|
||||||
|
/>
|
||||||
{options.showHistory && history.length > 0 && (
|
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||||
<>
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
<Divider />
|
<Text size="xs" fw={500} truncate>
|
||||||
<Stack gap={6}>
|
{entry.name}
|
||||||
<Group gap={6}>
|
</Text>
|
||||||
<IconHistory size={14} opacity={0.5} />
|
<Text size="xs" c="dimmed" truncate>
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
{systemName}
|
||||||
{t("history")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{history.map((entry) => {
|
|
||||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
|
||||||
const isResolved = !!entry.resolved;
|
|
||||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
|
||||||
return (
|
|
||||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
|
||||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Box
|
|
||||||
w={3}
|
|
||||||
h={24}
|
|
||||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
|
||||||
bg={isResolved ? "green.6" : "red.6"}
|
|
||||||
/>
|
|
||||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
|
||||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
|
||||||
<Text size="xs" fw={500} truncate>
|
|
||||||
{entry.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" truncate>
|
|
||||||
{systemName}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
|
||||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
|
||||||
{isResolved ? t("resolved") : t("status.triggered")}
|
|
||||||
</Badge>
|
|
||||||
<Text size="10px" c="dimmed">
|
|
||||||
{dayjs(entry.created).fromNow()}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||||
})}
|
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||||
</Stack>
|
{isResolved ? t("resolved") : t("status.triggered")}
|
||||||
</>
|
</Badge>
|
||||||
)}
|
<Text size="10px" c="dimmed">
|
||||||
</Stack>
|
{dayjs(entry.created).fromNow()}
|
||||||
</ScrollArea>
|
</Text>
|
||||||
</Box>
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
.clickableCard {
|
|
||||||
transition: background-color 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickableCard:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MantineSize } from "@mantine/core";
|
import type { MantineSize } from "@mantine/core";
|
||||||
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
|
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Battery,
|
Battery,
|
||||||
@@ -15,22 +15,16 @@ import {
|
|||||||
Thermometer,
|
Thermometer,
|
||||||
Wifi,
|
Wifi,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconServerOff } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import classes from "./component.module.css";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
|
||||||
|
|
||||||
interface SizeConfig {
|
interface SizeConfig {
|
||||||
iconSize: number;
|
iconSize: number;
|
||||||
@@ -160,7 +154,6 @@ interface SystemCardProps {
|
|||||||
size: SizeConfig;
|
size: SizeConfig;
|
||||||
maxMetrics: number;
|
maxMetrics: number;
|
||||||
itemRadius: MantineSize;
|
itemRadius: MantineSize;
|
||||||
onClick?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricRenderers = [
|
const metricRenderers = [
|
||||||
@@ -317,7 +310,7 @@ const metricRenderers = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => {
|
||||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -326,14 +319,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
|||||||
radius={itemRadius}
|
radius={itemRadius}
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
h="100%"
|
h="100%"
|
||||||
onClick={onClick}
|
|
||||||
className={onClick ? classes.clickableCard : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column" as const,
|
flexDirection: "column" as const,
|
||||||
border: "0.0625rem solid var(--border-color)",
|
border: "0.0625rem solid var(--border-color)",
|
||||||
cursor: onClick ? "pointer" : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="xs" mb={2}>
|
<Group gap="xs" mb={2}>
|
||||||
@@ -363,13 +353,8 @@ export default function BeszelSystemGridWidget({
|
|||||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
|
||||||
|
|
||||||
const {
|
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
data: results = [],
|
|
||||||
error: systemsError,
|
|
||||||
isPending,
|
|
||||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||||
);
|
);
|
||||||
@@ -379,30 +364,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
|
|
||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<Center h="100%">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredSystems.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
|
||||||
<BeszelIntegrationErrorIndicator results={results} />
|
|
||||||
<Center h="100%">
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<IconServerOff size={28} opacity={0.5} />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("empty.noSystems")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cols = getColCount(width, filteredSystems.length);
|
const cols = getColCount(width, filteredSystems.length);
|
||||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||||
const rawCellHeight = height / rows;
|
const rawCellHeight = height / rows;
|
||||||
@@ -413,37 +374,28 @@ export default function BeszelSystemGridWidget({
|
|||||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<Box
|
||||||
<BeszelIntegrationErrorIndicator results={results} />
|
h="100%"
|
||||||
<Box
|
style={{
|
||||||
h="100%"
|
pointerEvents: isEditMode ? "none" : undefined,
|
||||||
style={{
|
display: "grid",
|
||||||
display: "grid",
|
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
gap: size.gap,
|
||||||
gap: size.gap,
|
overflow: scrollEnabled ? "auto" : "hidden",
|
||||||
overflow: scrollEnabled ? "auto" : "hidden",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{filteredSystems.map((system) => (
|
||||||
{filteredSystems.map((system) => {
|
<SystemCard
|
||||||
const integrationId = system._key.split(":")[0] ?? "";
|
key={system._key}
|
||||||
const handleClick = isEditMode
|
system={system}
|
||||||
? undefined
|
options={options}
|
||||||
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
t={t}
|
||||||
return (
|
size={size}
|
||||||
<SystemCard
|
maxMetrics={maxMetrics}
|
||||||
key={system._key}
|
itemRadius={board.itemRadius}
|
||||||
system={system}
|
/>
|
||||||
options={options}
|
))}
|
||||||
t={t}
|
|
||||||
size={size}
|
|
||||||
maxMetrics={maxMetrics}
|
|
||||||
itemRadius={board.itemRadius}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -15,7 +15,6 @@ import classes from "./component.module.css";
|
|||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { containerColors } from "../beszel/_shared/colors";
|
import { containerColors } from "../beszel/_shared/colors";
|
||||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
|
||||||
import {
|
import {
|
||||||
BeszelChartPanel,
|
BeszelChartPanel,
|
||||||
CPU_Y_AXIS_DOMAIN,
|
CPU_Y_AXIS_DOMAIN,
|
||||||
@@ -30,7 +29,6 @@ import {
|
|||||||
formatPercent,
|
formatPercent,
|
||||||
formatStorageBytes,
|
formatStorageBytes,
|
||||||
} from "../beszel/_shared/format";
|
} from "../beszel/_shared/format";
|
||||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
|
||||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||||
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
||||||
|
|
||||||
@@ -99,18 +97,13 @@ export default function BeszelSystemStatsWidget({
|
|||||||
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
||||||
includeDocker: showDocker,
|
includeDocker: showDocker,
|
||||||
},
|
},
|
||||||
{
|
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false },
|
||||||
staleTime: 30 * 1000,
|
|
||||||
refetchInterval: isLive ? false : 5_000,
|
|
||||||
enabled: !isLive && systemReady && selectedSystem !== "",
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
||||||
|
|
||||||
let activeStats:
|
let activeStats:
|
||||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
|
||||||
| null
|
| null
|
||||||
| undefined = statsResult;
|
| undefined = statsResult;
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
@@ -160,31 +153,30 @@ export default function BeszelSystemStatsWidget({
|
|||||||
const systemStats = activeStats?.systemStats;
|
const systemStats = activeStats?.systemStats;
|
||||||
const containerStatsRaw = activeStats?.containerStats;
|
const containerStatsRaw = activeStats?.containerStats;
|
||||||
|
|
||||||
const timePeriod = options.timePeriod as BeszelTimePeriod;
|
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
|
||||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
|
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
|
||||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
|
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
|
||||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
|
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
|
||||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
|
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
|
||||||
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
|
|
||||||
|
|
||||||
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
|
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
|
||||||
const dockerCpuData = useDockerChartData(
|
const dockerCpuData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"cpu",
|
"cpu",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
const dockerMemData = useDockerChartData(
|
const dockerMemData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"memory",
|
"memory",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
const dockerNetData = useDockerChartData(
|
const dockerNetData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"network",
|
"network",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerSeries = useMemo(
|
const containerSeries = useMemo(
|
||||||
@@ -197,30 +189,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
if (!isLive && statsError) throw statsError;
|
if (!isLive && statsError) throw statsError;
|
||||||
|
|
||||||
if (systemsPending) {
|
|
||||||
return (
|
|
||||||
<Center h="100%">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (systems.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box h="100%" pos="relative">
|
|
||||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
|
||||||
<Center h="100%">
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<IconServerOff size={28} opacity={0.5} />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("empty.noSystems")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -258,186 +226,164 @@ export default function BeszelSystemStatsWidget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<ScrollArea
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
h="100%"
|
||||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
className={classes.beszelStatsContainer}
|
||||||
</Box>
|
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||||
<ScrollArea
|
>
|
||||||
h="100%"
|
<Stack gap="md" p="sm">
|
||||||
className={classes.beszelStatsContainer}
|
{!isEditMode && systems.length > 1 && (
|
||||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||||
>
|
<Menu.Target>
|
||||||
<Stack gap="md" p="sm">
|
<Button
|
||||||
{!isEditMode && systems.length > 1 && (
|
variant="default"
|
||||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
size="compact-xs"
|
||||||
<Menu.Target>
|
leftSection={<IconServer size={14} />}
|
||||||
<Button
|
className={classes.beszelStatsSystemToggle}
|
||||||
variant="default"
|
>
|
||||||
size="compact-xs"
|
{selectedLabel}
|
||||||
leftSection={<IconServer size={14} />}
|
</Button>
|
||||||
className={classes.beszelStatsSystemToggle}
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{systems.map((s) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={s.value}
|
||||||
|
fz="xs"
|
||||||
|
fw={s.value === selectedSystem ? 600 : 400}
|
||||||
|
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||||
|
onClick={() => handleSelectSystem(s.value)}
|
||||||
>
|
>
|
||||||
{selectedLabel}
|
{s.label}
|
||||||
</Button>
|
</Menu.Item>
|
||||||
</Menu.Target>
|
))}
|
||||||
<Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
{systems.map((s) => (
|
</Menu>
|
||||||
<Menu.Item
|
)}
|
||||||
key={s.value}
|
|
||||||
fz="xs"
|
|
||||||
fw={s.value === selectedSystem ? 600 : 400}
|
|
||||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
|
||||||
onClick={() => handleSelectSystem(s.value)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
{activeStats && (
|
||||||
<Center h={CHART_HEIGHT}>
|
<SimpleGrid cols={cols} spacing="md">
|
||||||
<Loader size="sm" />
|
{options.showCpu && cpuData.length > 0 && (
|
||||||
</Center>
|
<BeszelChartPanel
|
||||||
)}
|
title={t("chart.cpu.title")}
|
||||||
|
subtitle={t("chart.cpu.subtitle")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: cpuData,
|
||||||
|
series: series.cpu,
|
||||||
|
yAxisFormatter: chartAxisFormatters.percent,
|
||||||
|
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||||
|
tooltipProps: tooltipPercent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
{options.showMemory && memoryData.length > 0 && (
|
||||||
<Center h={CHART_HEIGHT}>
|
<BeszelChartPanel
|
||||||
<Stack align="center" gap="xs">
|
title={t("chart.memory.title")}
|
||||||
<IconServerOff size={24} opacity={0.5} />
|
subtitle={t("chart.memory.subtitle")}
|
||||||
<Text size="sm" c="dimmed">
|
chartProps={{
|
||||||
{activeStats.error}
|
h: CHART_HEIGHT,
|
||||||
</Text>
|
data: memoryData,
|
||||||
</Stack>
|
type: "stacked",
|
||||||
</Center>
|
series: series.memory,
|
||||||
)}
|
yAxisFormatter: chartAxisFormatters.gb,
|
||||||
|
tooltipProps: tooltipGB,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
|
{options.showDisk && diskData.length > 0 && (
|
||||||
<SimpleGrid cols={cols} spacing="md">
|
<BeszelChartPanel
|
||||||
{options.showCpu && cpuData.length > 0 && (
|
title={t("chart.disk.title")}
|
||||||
<BeszelChartPanel
|
subtitle={t("chart.disk.subtitle")}
|
||||||
title={t("chart.cpu.title")}
|
chartProps={{
|
||||||
subtitle={t("chart.cpu.subtitle")}
|
h: CHART_HEIGHT,
|
||||||
chartProps={{
|
data: diskData,
|
||||||
h: CHART_HEIGHT,
|
series: series.disk,
|
||||||
data: cpuData,
|
yAxisFormatter: chartAxisFormatters.gb,
|
||||||
series: series.cpu,
|
tooltipProps: tooltipGB,
|
||||||
yAxisFormatter: chartAxisFormatters.percent,
|
}}
|
||||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
/>
|
||||||
tooltipProps: tooltipPercent,
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{options.showMemory && memoryData.length > 0 && (
|
{options.showDiskIO && diskIOData.length > 0 && (
|
||||||
<BeszelChartPanel
|
<BeszelChartPanel
|
||||||
title={t("chart.memory.title")}
|
title={t("chart.diskIO.title")}
|
||||||
subtitle={t("chart.memory.subtitle")}
|
subtitle={t("chart.diskIO.subtitle")}
|
||||||
chartProps={{
|
chartProps={{
|
||||||
h: CHART_HEIGHT,
|
h: CHART_HEIGHT,
|
||||||
data: memoryData,
|
data: diskIOData,
|
||||||
type: "stacked",
|
series: series.diskIO,
|
||||||
series: series.memory,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
yAxisFormatter: chartAxisFormatters.gb,
|
tooltipProps: tooltipRate,
|
||||||
tooltipProps: tooltipGB,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{options.showDisk && diskData.length > 0 && (
|
{options.showNetwork && networkData.length > 0 && (
|
||||||
<BeszelChartPanel
|
<BeszelChartPanel
|
||||||
title={t("chart.disk.title")}
|
title={t("chart.network.title")}
|
||||||
subtitle={t("chart.disk.subtitle")}
|
subtitle={t("chart.network.subtitle")}
|
||||||
chartProps={{
|
chartProps={{
|
||||||
h: CHART_HEIGHT,
|
h: CHART_HEIGHT,
|
||||||
data: diskData,
|
data: networkData,
|
||||||
series: series.disk,
|
series: series.network,
|
||||||
yAxisFormatter: chartAxisFormatters.gb,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
tooltipProps: tooltipGB,
|
tooltipProps: tooltipRate,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{options.showDiskIO && diskIOData.length > 0 && (
|
{showDocker && containerSeries.length > 0 && (
|
||||||
<BeszelChartPanel
|
<>
|
||||||
title={t("chart.diskIO.title")}
|
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||||
subtitle={t("chart.diskIO.subtitle")}
|
<BeszelChartPanel
|
||||||
chartProps={{
|
title={t("chart.dockerCpu.title")}
|
||||||
h: CHART_HEIGHT,
|
subtitle={t("chart.dockerCpu.subtitle")}
|
||||||
data: diskIOData,
|
chartProps={{
|
||||||
series: series.diskIO,
|
h: CHART_HEIGHT,
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
data: dockerCpuData,
|
||||||
tooltipProps: tooltipRate,
|
type: "stacked",
|
||||||
}}
|
series: containerSeries,
|
||||||
/>
|
yAxisFormatter: chartAxisFormatters.percent,
|
||||||
)}
|
tooltipProps: tooltipPercentTotal,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{options.showNetwork && networkData.length > 0 && (
|
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||||
<BeszelChartPanel
|
<BeszelChartPanel
|
||||||
title={t("chart.network.title")}
|
title={t("chart.dockerMemory.title")}
|
||||||
subtitle={t("chart.network.subtitle")}
|
subtitle={t("chart.dockerMemory.subtitle")}
|
||||||
chartProps={{
|
chartProps={{
|
||||||
h: CHART_HEIGHT,
|
h: CHART_HEIGHT,
|
||||||
data: networkData,
|
data: dockerMemData,
|
||||||
series: series.network,
|
type: "stacked",
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
series: containerSeries,
|
||||||
tooltipProps: tooltipRate,
|
yAxisFormatter: chartAxisFormatters.bytes,
|
||||||
}}
|
tooltipProps: tooltipBytesTotal,
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showDocker && containerSeries.length > 0 && (
|
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||||
<>
|
<BeszelChartPanel
|
||||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
title={t("chart.dockerNetwork.title")}
|
||||||
<BeszelChartPanel
|
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||||
title={t("chart.dockerCpu.title")}
|
chartProps={{
|
||||||
subtitle={t("chart.dockerCpu.subtitle")}
|
h: CHART_HEIGHT,
|
||||||
chartProps={{
|
data: dockerNetData,
|
||||||
h: CHART_HEIGHT,
|
series: containerSeries,
|
||||||
data: dockerCpuData,
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
type: "stacked",
|
tooltipProps: tooltipRate,
|
||||||
series: containerSeries,
|
}}
|
||||||
yAxisFormatter: chartAxisFormatters.percent,
|
/>
|
||||||
tooltipProps: tooltipPercentTotal,
|
)}
|
||||||
}}
|
</>
|
||||||
/>
|
)}
|
||||||
)}
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
</Stack>
|
||||||
<BeszelChartPanel
|
</ScrollArea>
|
||||||
title={t("chart.dockerMemory.title")}
|
|
||||||
subtitle={t("chart.dockerMemory.subtitle")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: dockerMemData,
|
|
||||||
type: "stacked",
|
|
||||||
series: containerSeries,
|
|
||||||
yAxisFormatter: chartAxisFormatters.bytes,
|
|
||||||
tooltipProps: tooltipBytesTotal,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.dockerNetwork.title")}
|
|
||||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: dockerNetData,
|
|
||||||
series: containerSeries,
|
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
|
||||||
tooltipProps: tooltipRate,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
||||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||||
import { DataTable } from "mantine-datatable";
|
import { DataTable } from "mantine-datatable";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
@@ -28,8 +27,6 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
|
|||||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
|
||||||
|
|
||||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||||
|
|
||||||
@@ -69,12 +66,7 @@ export default function BeszelSystemTableWidget({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||||
const t = useScopedI18n("widget.beszelSystemTable");
|
const t = useScopedI18n("widget.beszelSystemTable");
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
const {
|
|
||||||
data: results = [],
|
|
||||||
error: systemsError,
|
|
||||||
isPending,
|
|
||||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||||
);
|
);
|
||||||
@@ -263,42 +255,23 @@ export default function BeszelSystemTableWidget({
|
|||||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||||
}, [options, t, size]);
|
}, [options, t, size]);
|
||||||
|
|
||||||
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
|
|
||||||
const integrationId = record._key.split(":")[0] ?? "";
|
|
||||||
openModal({ integrationId, systemId: record.id }, { title: record.name });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<Center h="100%">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative", height: "100%" }}>
|
<DataTable
|
||||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
<BeszelIntegrationErrorIndicator results={results} />
|
withTableBorder={false}
|
||||||
</div>
|
borderRadius={0}
|
||||||
<DataTable
|
highlightOnHover
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
fz={size.fontSize}
|
||||||
withTableBorder={false}
|
records={sortedSystems}
|
||||||
borderRadius={0}
|
columns={columns}
|
||||||
highlightOnHover
|
sortStatus={sortStatus}
|
||||||
fz={size.fontSize}
|
onSortStatusChange={setSortStatus}
|
||||||
records={sortedSystems}
|
noRecordsText={t("noRecords")}
|
||||||
columns={columns}
|
idAccessor="_key"
|
||||||
sortStatus={sortStatus}
|
height="100%"
|
||||||
onSortStatusChange={setSortStatus}
|
className="beszel-table"
|
||||||
noRecordsText={t("noRecords")}
|
/>
|
||||||
idAccessor="_key"
|
|
||||||
height="100%"
|
|
||||||
className="beszel-table"
|
|
||||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,23 +6,14 @@
|
|||||||
.beszel-table .mantine-datatable-table thead,
|
.beszel-table .mantine-datatable-table thead,
|
||||||
.beszel-table .mantine-datatable-table tbody,
|
.beszel-table .mantine-datatable-table tbody,
|
||||||
.beszel-table .mantine-datatable-table tfoot,
|
.beszel-table .mantine-datatable-table tfoot,
|
||||||
|
.beszel-table .mantine-datatable-table tr,
|
||||||
.beszel-table th,
|
.beszel-table th,
|
||||||
.beszel-table td {
|
.beszel-table td {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beszel-table .mantine-datatable-table tr {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beszel-table .mantine-datatable-table tbody tr:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.beszel-table th {
|
.beszel-table th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.beszel-table td {
|
.beszel-table td {
|
||||||
|
|||||||
@@ -8,46 +8,14 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||||
|
|
||||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
||||||
|
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
|
||||||
|
|
||||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
function prepareRecords<T>(records: T[], live: boolean) {
|
||||||
"1m": "HH:mm:ss",
|
if (live) {
|
||||||
"1h": "HH:mm",
|
return { fmt: formatTimeLive, ordered: records };
|
||||||
"12h": "HH:mm",
|
|
||||||
"24h": "HH:mm",
|
|
||||||
"1w": "MMM D",
|
|
||||||
"30d": "MMM D",
|
|
||||||
};
|
|
||||||
|
|
||||||
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
|
||||||
|
|
||||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
|
||||||
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
|
|
||||||
if (timePeriod === "1m") {
|
|
||||||
return { fmt, ordered: records };
|
|
||||||
}
|
}
|
||||||
return { fmt, ordered: [...records].toReversed() };
|
return { fmt: formatTime, ordered: [...records].toReversed() };
|
||||||
}
|
|
||||||
|
|
||||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
|
||||||
const days = periodDays[timePeriod];
|
|
||||||
if (!days) return data;
|
|
||||||
|
|
||||||
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
|
|
||||||
const now = dayjs();
|
|
||||||
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
|
|
||||||
const result = [...data];
|
|
||||||
|
|
||||||
for (let i = days; i >= 0; i--) {
|
|
||||||
const d = now.subtract(i, "day").startOf("day");
|
|
||||||
const key = d.format("YYYY-MM-DD");
|
|
||||||
if (!existingDays.has(key)) {
|
|
||||||
const iso = d.toISOString();
|
|
||||||
result.push({ time: fmt(iso), rawTime: iso });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||||
@@ -109,7 +77,7 @@ const BeszelAreaChart = memo(
|
|||||||
dataKey="time"
|
dataKey="time"
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
withGradient={false}
|
withGradient={false}
|
||||||
connectNulls={false}
|
connectNulls
|
||||||
withDots={false}
|
withDots={false}
|
||||||
type={type}
|
type={type}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
@@ -128,18 +96,16 @@ const BeszelAreaChart = memo(
|
|||||||
export const useSystemChartData = (
|
export const useSystemChartData = (
|
||||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||||
timePeriod: BeszelTimePeriod = "1h",
|
live = false,
|
||||||
) =>
|
) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!systemStats) return [];
|
if (!systemStats) return [];
|
||||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
const { fmt, ordered } = prepareRecords(systemStats, live);
|
||||||
const mapped = ordered.map((r) => ({
|
return ordered.map((r) => ({
|
||||||
time: fmt(r.created),
|
time: fmt(r.created),
|
||||||
rawTime: r.created,
|
|
||||||
...mapFn(r.stats),
|
...mapFn(r.stats),
|
||||||
}));
|
}));
|
||||||
return padTimeGrid(mapped, timePeriod);
|
}, [systemStats, mapFn, live]);
|
||||||
}, [systemStats, mapFn, timePeriod]);
|
|
||||||
|
|
||||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||||
const prevRef = useRef<string[]>([]);
|
const prevRef = useRef<string[]>([]);
|
||||||
@@ -178,20 +144,19 @@ export const useDockerChartData = (
|
|||||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||||
containerNames: string[],
|
containerNames: string[],
|
||||||
metric: "cpu" | "memory" | "network",
|
metric: "cpu" | "memory" | "network",
|
||||||
timePeriod: BeszelTimePeriod = "1h",
|
live = false,
|
||||||
) =>
|
) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!containerStats?.length) return [];
|
if (!containerStats?.length) return [];
|
||||||
const extract = defaultContainerExtractors[metric];
|
const extract = defaultContainerExtractors[metric];
|
||||||
if (!extract) return [];
|
if (!extract) return [];
|
||||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
const { fmt, ordered } = prepareRecords(containerStats, live);
|
||||||
const mapped = ordered.map((record) => {
|
return ordered.map((record) => {
|
||||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
const point: Record<string, unknown> = { time: fmt(record.created) };
|
||||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||||
for (const name of containerNames) {
|
for (const name of containerNames) {
|
||||||
point[name] = extract(byName.get(name));
|
point[name] = extract(byName.get(name));
|
||||||
}
|
}
|
||||||
return point;
|
return point;
|
||||||
});
|
});
|
||||||
return padTimeGrid(mapped, timePeriod);
|
}, [containerStats, containerNames, metric, live]);
|
||||||
}, [containerStats, containerNames, metric, timePeriod]);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Tooltip } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
interface IntegrationResult {
|
|
||||||
integrationId: string;
|
|
||||||
integrationName?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BeszelIntegrationErrorIndicatorProps {
|
|
||||||
results: IntegrationResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
|
||||||
const failed = results.filter((r) => "error" in r && r.error);
|
|
||||||
if (failed.length === 0) return null;
|
|
||||||
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
|
||||||
return (
|
|
||||||
<Tooltip label={label} position="left" withArrow>
|
|
||||||
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,10 +31,7 @@ export const useBeszelFilteredSystems = (
|
|||||||
statusFilter: string,
|
statusFilter: string,
|
||||||
): SystemRowWithKey[] => {
|
): SystemRowWithKey[] => {
|
||||||
const allSystems = useMemo(
|
const allSystems = useMemo(
|
||||||
() =>
|
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
|
||||||
results
|
|
||||||
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
|
|
||||||
.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
[results],
|
[results],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { createModal } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { BeszelTimePeriod } from "./chart";
|
|
||||||
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
|
|
||||||
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
|
|
||||||
import { makeTooltipProps } from "./tooltip";
|
|
||||||
|
|
||||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
|
||||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
|
||||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
|
||||||
|
|
||||||
const CHART_HEIGHT = 180;
|
|
||||||
const MB = 1024 * 1024;
|
|
||||||
|
|
||||||
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
|
|
||||||
{ value: "1h", label: "1H" },
|
|
||||||
{ value: "12h", label: "12H" },
|
|
||||||
{ value: "24h", label: "24H" },
|
|
||||||
{ value: "1w", label: "1W" },
|
|
||||||
{ value: "30d", label: "30D" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface BeszelSystemStatsModalProps {
|
|
||||||
integrationId: string;
|
|
||||||
systemId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
|
|
||||||
const t = useScopedI18n("widget.beszelSystemStats");
|
|
||||||
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
|
|
||||||
|
|
||||||
const { data } = clientApi.widget.beszel.getSystemStats.useQuery(
|
|
||||||
{
|
|
||||||
integrationIds: [innerProps.integrationId],
|
|
||||||
systemId: innerProps.systemId,
|
|
||||||
timePeriod,
|
|
||||||
includeDocker: false,
|
|
||||||
},
|
|
||||||
{ staleTime: 30_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const mappers = useMemo(
|
|
||||||
() => ({
|
|
||||||
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
|
|
||||||
memory: (s: { mu: number; mb: number }) => ({
|
|
||||||
[t("chart.memory.series")]: s.mu,
|
|
||||||
[t("chart.memory.cache")]: s.mb ?? 0,
|
|
||||||
}),
|
|
||||||
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
|
|
||||||
diskIO: (s: { dr?: number; dw?: number }) => ({
|
|
||||||
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
|
|
||||||
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
|
|
||||||
}),
|
|
||||||
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
|
|
||||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
|
||||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const series = useMemo(
|
|
||||||
() => ({
|
|
||||||
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
|
|
||||||
memory: [
|
|
||||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
|
||||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
|
||||||
],
|
|
||||||
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
|
|
||||||
diskIO: [
|
|
||||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
|
||||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
|
||||||
],
|
|
||||||
network: [
|
|
||||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
|
||||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const systemStats = data?.systemStats;
|
|
||||||
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
|
|
||||||
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
|
|
||||||
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
|
|
||||||
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
|
|
||||||
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<SegmentedControl
|
|
||||||
size="xs"
|
|
||||||
value={timePeriod}
|
|
||||||
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
|
|
||||||
data={timePeriodOptions}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
{cpuData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.cpu.title")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: cpuData,
|
|
||||||
series: series.cpu,
|
|
||||||
yAxisFormatter: chartAxisFormatters.percent,
|
|
||||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
|
||||||
tooltipProps: tooltipPercent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{memoryData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.memory.title")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: memoryData,
|
|
||||||
type: "stacked",
|
|
||||||
series: series.memory,
|
|
||||||
yAxisFormatter: chartAxisFormatters.gb,
|
|
||||||
tooltipProps: tooltipGB,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{diskData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.disk.title")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: diskData,
|
|
||||||
series: series.disk,
|
|
||||||
yAxisFormatter: chartAxisFormatters.gb,
|
|
||||||
tooltipProps: tooltipGB,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{diskIOData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.diskIO.title")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: diskIOData,
|
|
||||||
series: series.diskIO,
|
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
|
||||||
tooltipProps: tooltipRate,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{networkData.length > 0 && (
|
|
||||||
<BeszelChartPanel
|
|
||||||
title={t("chart.network.title")}
|
|
||||||
chartProps={{
|
|
||||||
h: CHART_HEIGHT,
|
|
||||||
data: networkData,
|
|
||||||
series: series.network,
|
|
||||||
yAxisFormatter: chartAxisFormatters.rate,
|
|
||||||
tooltipProps: tooltipRate,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}).withOptions({
|
|
||||||
defaultTitle: (t) => t("widget.beszelSystemStats.name"),
|
|
||||||
size: 1200,
|
|
||||||
});
|
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import dayjs from "dayjs";
|
|
||||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
|
||||||
|
|
||||||
dayjs.extend(localizedFormat);
|
|
||||||
|
|
||||||
const styles: Record<string, CSSProperties> = {
|
const styles: Record<string, CSSProperties> = {
|
||||||
wrapper: {
|
wrapper: {
|
||||||
@@ -44,7 +40,6 @@ export interface TooltipPayloadItem {
|
|||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
dataKey: string;
|
dataKey: string;
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortalTooltipProps {
|
interface PortalTooltipProps {
|
||||||
@@ -55,39 +50,16 @@ interface PortalTooltipProps {
|
|||||||
showTotal?: boolean;
|
showTotal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARGIN = 14;
|
|
||||||
|
|
||||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||||
const mouseRef = useRef({ x: 0, y: 0 });
|
const mouseRef = useRef({ x: 0, y: 0 });
|
||||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleMouse = useCallback((e: MouseEvent) => {
|
const handleMouse = useCallback((e: MouseEvent) => {
|
||||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
const el = tooltipRef.current;
|
setMouse(mouseRef.current);
|
||||||
const mx = mouseRef.current.x;
|
|
||||||
const my = mouseRef.current.y;
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
setPos({ x: mx + MARGIN, y: my });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height } = el.getBoundingClientRect();
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
|
|
||||||
let x = mx + MARGIN;
|
|
||||||
let y = my - height / 2;
|
|
||||||
|
|
||||||
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
|
|
||||||
if (y < MARGIN) y = MARGIN;
|
|
||||||
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
|
|
||||||
|
|
||||||
setPos({ x, y });
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -112,22 +84,21 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
|||||||
|
|
||||||
if (!sorted.length) return null;
|
if (!sorted.length) return null;
|
||||||
|
|
||||||
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
|
|
||||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
|
||||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||||
|
|
||||||
const portalStyle: CSSProperties = {
|
const portalStyle: CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: pos.x,
|
left: mouse.x + 14,
|
||||||
top: pos.y,
|
top: mouse.y,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div ref={tooltipRef} style={portalStyle}>
|
<div style={portalStyle}>
|
||||||
<div style={styles.wrapper}>
|
<div style={styles.wrapper}>
|
||||||
<div style={styles.header}>{tooltipLabel}</div>
|
<div style={styles.header}>{label}</div>
|
||||||
{sorted.map((item) => (
|
{sorted.map((item) => (
|
||||||
<div key={item.dataKey} style={styles.row}>
|
<div key={item.dataKey} style={styles.row}>
|
||||||
<div style={{ ...styles.indicator, background: item.color }} />
|
<div style={{ ...styles.indicator, background: item.color }} />
|
||||||
|
|||||||
@@ -95,12 +95,6 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
|||||||
return !integrationKinds.includes("proxmox");
|
return !integrationKinds.includes("proxmox");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultTab: {
|
|
||||||
shouldHide(_, integrationKinds) {
|
|
||||||
// The system/cluster tabs only render for Proxmox, so the default tab choice does nothing otherwise
|
|
||||||
return !integrationKinds.includes("proxmox");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,6 @@ import duration from "dayjs/plugin/duration";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { humanFileSize } from "@homarr/common";
|
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export const SystemHealthMonitoring = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||||
return (
|
return (
|
||||||
@@ -196,6 +196,11 @@ export const SystemHealthMonitoring = ({
|
|||||||
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
|
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{
|
||||||
|
<Text className="health-monitoring-status-update-time" c="dimmed" size="xs" ta="center">
|
||||||
|
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
{options.fileSystem &&
|
{options.fileSystem &&
|
||||||
disksData.map((disk) => {
|
disksData.map((disk) => {
|
||||||
return (
|
return (
|
||||||
@@ -209,7 +214,7 @@ export const SystemHealthMonitoring = ({
|
|||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
p="xs"
|
p="xs"
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="sm">
|
||||||
<Group
|
<Group
|
||||||
className="health-monitoring-disk-status"
|
className="health-monitoring-disk-status"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -223,16 +228,14 @@ export const SystemHealthMonitoring = ({
|
|||||||
{disk.deviceName}
|
{disk.deviceName}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{disk.temperature !== null && (
|
<Group gap={4} wrap="nowrap">
|
||||||
<Group gap={4} wrap="nowrap">
|
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
||||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
<Text className="health-monitoring-disk-temperature-value" size="xs">
|
||||||
<Text className="health-monitoring-disk-temperature-value" size="xs">
|
{options.fahrenheit
|
||||||
{options.fahrenheit
|
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
: `${disk.temperature}°C`}
|
||||||
: `${disk.temperature}°C`}
|
</Text>
|
||||||
</Text>
|
</Group>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
||||||
<Text className="health-monitoring-disk-status-value" size="xs">
|
<Text className="health-monitoring-disk-status-value" size="xs">
|
||||||
@@ -240,26 +243,37 @@ export const SystemHealthMonitoring = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} size="lg">
|
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} h="md">
|
||||||
<Progress.Section
|
<Tooltip label={disk.used}>
|
||||||
value={disk.percentage}
|
<Progress.Section
|
||||||
color={progressColor(disk.percentage)}
|
value={disk.percentage}
|
||||||
className="health-monitoring-disk-use-percentage"
|
color={progressColor(disk.percentage)}
|
||||||
/>
|
className="health-monitoring-disk-use-percentage"
|
||||||
<Progress.Section
|
>
|
||||||
className="health-monitoring-disk-available-percentage"
|
<Progress.Label className="health-monitoring-disk-use-value" fz="xs">
|
||||||
value={100 - disk.percentage}
|
{t("widget.healthMonitoring.popover.used")}
|
||||||
color="default"
|
</Progress.Label>
|
||||||
/>
|
</Progress.Section>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
Number(disk.available) / 1024 ** 4 >= 1
|
||||||
|
? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB`
|
||||||
|
: `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Progress.Section
|
||||||
|
className="health-monitoring-disk-available-percentage"
|
||||||
|
value={100 - disk.percentage}
|
||||||
|
color="default"
|
||||||
|
>
|
||||||
|
<Progress.Label className="health-monitoring-disk-available-value" fz="xs">
|
||||||
|
{t("widget.healthMonitoring.popover.available")}
|
||||||
|
</Progress.Label>
|
||||||
|
</Progress.Section>
|
||||||
|
</Tooltip>
|
||||||
</Progress.Root>
|
</Progress.Root>
|
||||||
<Group justify="space-between" gap={8} wrap="nowrap">
|
|
||||||
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
|
|
||||||
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
|
|
||||||
</Text>
|
|
||||||
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
|
|
||||||
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -293,13 +307,6 @@ export const progressColor = (percentage: number) => {
|
|||||||
else return "red";
|
else return "red";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Some integrations report file sizes as raw bytes (e.g. TrueNAS, Glances) while others pre-format
|
|
||||||
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
|
|
||||||
const formatFileSize = (value: string) => {
|
|
||||||
const bytes = Number(value);
|
|
||||||
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FileSystem {
|
interface FileSystem {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
used: string;
|
used: string;
|
||||||
@@ -324,7 +331,7 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
|
|||||||
used: fileSystem.used,
|
used: fileSystem.used,
|
||||||
available: fileSystem.available,
|
available: fileSystem.available,
|
||||||
percentage: fileSystem.percentage,
|
percentage: fileSystem.percentage,
|
||||||
temperature: smartDisk?.temperature ?? null,
|
temperature: smartDisk?.temperature ?? 0,
|
||||||
overallStatus: smartDisk?.overallStatus ?? "",
|
overallStatus: smartDisk?.overallStatus ?? "",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { Box, Card, Group, Stack, useMantineColorScheme } from "@mantine/core";
|
||||||
import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
@@ -32,90 +31,6 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SystemDiskCardProps {
|
|
||||||
deviceName: string;
|
|
||||||
percentage: number;
|
|
||||||
displayText: string;
|
|
||||||
temperature: number | null | undefined;
|
|
||||||
healthy: boolean;
|
|
||||||
showBackgroundBar: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemDiskCard = ({
|
|
||||||
deviceName,
|
|
||||||
percentage,
|
|
||||||
displayText,
|
|
||||||
temperature,
|
|
||||||
healthy,
|
|
||||||
showBackgroundBar,
|
|
||||||
}: SystemDiskCardProps) => {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const scheme = useMantineColorScheme();
|
|
||||||
const t = useI18n();
|
|
||||||
const valueRef = useRef<HTMLParagraphElement>(null);
|
|
||||||
const [valueFits, setValueFits] = useState(true);
|
|
||||||
|
|
||||||
// When the card is squeezed (small widget), the value line is clipped by the card's `overflow: hidden`.
|
|
||||||
// Detect that and hide it, surfacing the value in a tooltip instead. The value keeps its layout space
|
|
||||||
// (visibility, not display) so toggling it does not change the measurement and flap. The ref lives on
|
|
||||||
// the value (not the Card) so the Tooltip can attach its own ref to the Card.
|
|
||||||
useEffect(() => {
|
|
||||||
const value = valueRef.current;
|
|
||||||
const card = value?.offsetParent as HTMLElement | null; // the position:relative Card
|
|
||||||
if (!value || !card) return;
|
|
||||||
const measure = () => {
|
|
||||||
setValueFits(value.getBoundingClientRect().bottom <= card.getBoundingClientRect().bottom + 1);
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(card);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [displayText, healthy]);
|
|
||||||
|
|
||||||
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={healthy ? displayText : `${displayText} (${unhealthyLabel})`}
|
|
||||||
disabled={valueFits}
|
|
||||||
position="top"
|
|
||||||
withinPortal
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
radius={board.itemRadius}
|
|
||||||
py={"xs"}
|
|
||||||
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
|
||||||
style={{ overflow: "hidden", position: "relative" }}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" style={{ zIndex: 1 }}>
|
|
||||||
<div>
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
<b>{deviceName}</b>
|
|
||||||
</p>
|
|
||||||
<p ref={valueRef} style={{ margin: 0, visibility: valueFits ? "visible" : "hidden" }}>
|
|
||||||
<span>{displayText}</span>
|
|
||||||
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>{temperature ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}</div>
|
|
||||||
</Group>
|
|
||||||
<Box
|
|
||||||
bg={healthy ? "green" : "red"}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: `${percentage}%`,
|
|
||||||
height: "100%",
|
|
||||||
zIndex: 0,
|
|
||||||
display: showBackgroundBar ? "block" : "none",
|
|
||||||
}}
|
|
||||||
></Box>
|
|
||||||
</Card>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
||||||
const queryInput = { integrationIds };
|
const queryInput = { integrationIds };
|
||||||
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput, {
|
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput, {
|
||||||
@@ -123,6 +38,10 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
|||||||
});
|
});
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const scheme = useMantineColorScheme();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
|
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
|
||||||
onData(data) {
|
onData(data) {
|
||||||
utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) =>
|
utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) =>
|
||||||
@@ -144,17 +63,45 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
|||||||
<Stack gap="xs" p="xs" h="100%">
|
<Stack gap="xs" p="xs" h="100%">
|
||||||
{fileSystem.map((item) => {
|
{fileSystem.map((item) => {
|
||||||
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
||||||
|
const healthy = smartItem?.healthy ?? true; // fall back to healthy if no information is available
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemDiskCard
|
<Card
|
||||||
|
radius={board.itemRadius}
|
||||||
|
py={"xs"}
|
||||||
|
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
||||||
key={`disk-${item.deviceName}`}
|
key={`disk-${item.deviceName}`}
|
||||||
deviceName={item.deviceName}
|
style={{ overflow: "hidden", position: "relative" }}
|
||||||
percentage={item.percentage}
|
>
|
||||||
displayText={getDisplayText(item, options.displayMode)}
|
<Group justify="space-between" style={{ zIndex: 1 }}>
|
||||||
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
|
<div>
|
||||||
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
|
<p style={{ margin: 0 }}>
|
||||||
showBackgroundBar={options.showBackgroundBar}
|
<b>{item.deviceName}</b>
|
||||||
/>
|
</p>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
<span>{getDisplayText(item, options.displayMode)}</span>
|
||||||
|
{!healthy && <span style={{ marginLeft: 5 }}>{t("widget.systemDisks.status.unhealthy")}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{smartItem?.temperature && options.showTemperatureIfAvailable ? (
|
||||||
|
<p style={{ margin: 0 }}>{smartItem.temperature}°C</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Box
|
||||||
|
bg={healthy ? "green" : "red"}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: `${item.percentage}%`,
|
||||||
|
height: "100%",
|
||||||
|
zIndex: 0,
|
||||||
|
display: options.showBackgroundBar ? "block" : "none",
|
||||||
|
}}
|
||||||
|
></Box>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Generated
+315
-335
File diff suppressed because it is too large.
Load diff
+20
-20
@@ -52,22 +52,22 @@ catalog:
|
|||||||
"@testcontainers/mysql": ^12.0.2
|
"@testcontainers/mysql": ^12.0.2
|
||||||
"@testcontainers/postgresql": ^12.0.2
|
"@testcontainers/postgresql": ^12.0.2
|
||||||
"@testcontainers/redis": ^12.0.2
|
"@testcontainers/redis": ^12.0.2
|
||||||
"@tiptap/extension-color": 3.27.0
|
"@tiptap/extension-color": 3.26.1
|
||||||
"@tiptap/extension-highlight": 3.27.0
|
"@tiptap/extension-highlight": 3.26.1
|
||||||
"@tiptap/extension-image": 3.27.0
|
"@tiptap/extension-image": 3.26.1
|
||||||
"@tiptap/extension-link": ^3.27.0
|
"@tiptap/extension-link": ^3.26.1
|
||||||
"@tiptap/extension-placeholder": ^3.27.0
|
"@tiptap/extension-placeholder": ^3.26.1
|
||||||
"@tiptap/extension-table": 3.27.0
|
"@tiptap/extension-table": 3.26.1
|
||||||
"@tiptap/extension-table-cell": 3.27.0
|
"@tiptap/extension-table-cell": 3.26.1
|
||||||
"@tiptap/extension-table-header": 3.27.0
|
"@tiptap/extension-table-header": 3.26.1
|
||||||
"@tiptap/extension-table-row": 3.27.0
|
"@tiptap/extension-table-row": 3.26.1
|
||||||
"@tiptap/extension-task-item": 3.27.0
|
"@tiptap/extension-task-item": 3.26.1
|
||||||
"@tiptap/extension-task-list": 3.27.0
|
"@tiptap/extension-task-list": 3.26.1
|
||||||
"@tiptap/extension-text-align": 3.27.0
|
"@tiptap/extension-text-align": 3.26.1
|
||||||
"@tiptap/extension-text-style": 3.27.0
|
"@tiptap/extension-text-style": 3.26.1
|
||||||
"@tiptap/extension-underline": 3.27.0
|
"@tiptap/extension-underline": 3.26.1
|
||||||
"@tiptap/react": ^3.27.0
|
"@tiptap/react": ^3.26.1
|
||||||
"@tiptap/starter-kit": ^3.27.0
|
"@tiptap/starter-kit": ^3.26.1
|
||||||
"@trpc/client": ^11.17.0
|
"@trpc/client": ^11.17.0
|
||||||
"@trpc/next": ^11.17.0
|
"@trpc/next": ^11.17.0
|
||||||
"@trpc/react-query": ^11.17.0
|
"@trpc/react-query": ^11.17.0
|
||||||
@@ -93,7 +93,7 @@ catalog:
|
|||||||
"@types/ws": ^8.18.1
|
"@types/ws": ^8.18.1
|
||||||
"@types/xml2js": ^0.4.14
|
"@types/xml2js": ^0.4.14
|
||||||
posthog-js: ^1.255.0
|
posthog-js: ^1.255.0
|
||||||
posthog-node: ^4.18.0
|
posthog-node: ^4.17.0
|
||||||
"@vitejs/plugin-react": ^6.0.2
|
"@vitejs/plugin-react": ^6.0.2
|
||||||
"@vitest/coverage-v8": ^4.1.8
|
"@vitest/coverage-v8": ^4.1.8
|
||||||
"@vitest/ui": ^4.1.8
|
"@vitest/ui": ^4.1.8
|
||||||
@@ -138,7 +138,7 @@ catalog:
|
|||||||
jsonpath-plus: ^10.4.0
|
jsonpath-plus: ^10.4.0
|
||||||
ldapts: 8.1.8
|
ldapts: 8.1.8
|
||||||
lodash.clonedeep: ^4.5.0
|
lodash.clonedeep: ^4.5.0
|
||||||
lucide-react: ^1.20.0
|
lucide-react: ^1.18.0
|
||||||
"mantine-contextmenu": ^9.3.0
|
"mantine-contextmenu": ^9.3.0
|
||||||
mantine-datatable: ^9.3.0
|
mantine-datatable: ^9.3.0
|
||||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||||
@@ -180,7 +180,7 @@ catalog:
|
|||||||
vite: ^8.0.16
|
vite: ^8.0.16
|
||||||
undici: 7.27.2
|
undici: 7.27.2
|
||||||
use-deep-compare-effect: ^1.8.1
|
use-deep-compare-effect: ^1.8.1
|
||||||
video.js: ^8.23.8
|
video.js: ^8.23.7
|
||||||
vite-tsconfig-paths: ^6.1.1
|
vite-tsconfig-paths: ^6.1.1
|
||||||
vitest: ^4.1.8
|
vitest: ^4.1.8
|
||||||
winston: 3.19.0
|
winston: 3.19.0
|
||||||
@@ -206,7 +206,7 @@ minimumReleaseAgeExclude:
|
|||||||
overrides:
|
overrides:
|
||||||
"@babel/helpers@<7.26.10": ">=7.29.7"
|
"@babel/helpers@<7.26.10": ">=7.29.7"
|
||||||
"@babel/runtime@<7.26.10": ">=7.29.7"
|
"@babel/runtime@<7.26.10": ">=7.29.7"
|
||||||
"axios@>=1.0.0 <1.8.2": ">=1.18.0"
|
"axios@>=1.0.0 <1.8.2": ">=1.17.0"
|
||||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
|
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
|
||||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
|
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
|
||||||
"esbuild@<=0.24.2": ">=0.28.0"
|
"esbuild@<=0.24.2": ">=0.28.0"
|
||||||
|
|||||||
Reference in new issue
Block a user