feat: add simple app ping (#580)
* feat: add simple ping * refactor: make ping run on server and show errors * fix: format issues * fix: missing translation for enabled ping option for app * refactor: remove ping queue as no longer needed * chore: address pull request feedback * test: add some unit tests * fix: format issues * fix: deepsource issues
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Box, Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconDeviceDesktopX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRegisterSpotlightActions } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -33,6 +35,19 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
|
||||
},
|
||||
);
|
||||
|
||||
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null);
|
||||
|
||||
const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
|
||||
clientApi.widget.app.updatedPing.useSubscription(
|
||||
{ url: app?.href ?? "" },
|
||||
{
|
||||
enabled: shouldRunPing,
|
||||
onData(data) {
|
||||
setPingResult(data);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useRegisterSpotlightActions(
|
||||
`app-${options.appId}`,
|
||||
app?.href
|
||||
@@ -77,7 +92,7 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
|
||||
|
||||
return (
|
||||
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}>
|
||||
<Flex align="center" justify="center" h="100%">
|
||||
<Flex align="center" justify="center" h="100%" pos="relative">
|
||||
<Tooltip.Floating
|
||||
label={app?.description}
|
||||
position="right-start"
|
||||
@@ -103,6 +118,8 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig
|
||||
<img src={app?.iconUrl} alt={app?.name} className={classes.appIcon} />
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
|
||||
{shouldRunPing && <PingIndicator pingResult={pingResult} />}
|
||||
</Flex>
|
||||
</AppLink>
|
||||
);
|
||||
@@ -122,3 +139,31 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
interface PingIndicatorProps {
|
||||
pingResult: RouterOutputs["widget"]["app"]["ping"] | null;
|
||||
}
|
||||
|
||||
const PingIndicator = ({ pingResult }: PingIndicatorProps) => {
|
||||
return (
|
||||
<Box bottom={4} right={4} pos="absolute">
|
||||
<Tooltip
|
||||
label={pingResult && "statusCode" in pingResult ? pingResult.statusCode : pingResult?.error}
|
||||
disabled={!pingResult}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: "100%",
|
||||
backgroundColor: !pingResult
|
||||
? "orange"
|
||||
: "error" in pingResult || pingResult.statusCode >= 500
|
||||
? "red"
|
||||
: "green",
|
||||
}}
|
||||
w={16}
|
||||
h={16}
|
||||
></Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
||||
appId: factory.app(),
|
||||
openInNewTab: factory.switch({ defaultValue: true }),
|
||||
showDescriptionTooltip: factory.switch({ defaultValue: false }),
|
||||
pingEnabled: factory.switch({ defaultValue: false }),
|
||||
})),
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
|
||||
export default async function getServerDataAsync({ options }: WidgetProps<"app">) {
|
||||
if (!options.appId) {
|
||||
return { app: null, pingResult: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const app = await api.app.byId({ id: options.appId });
|
||||
return { app };
|
||||
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
|
||||
|
||||
if (app.href && options.pingEnabled) {
|
||||
pingResult = await api.widget.app.ping({ url: app.href });
|
||||
}
|
||||
|
||||
return { app, pingResult };
|
||||
} catch (error) {
|
||||
return { app: null };
|
||||
return { app: null, pingResult: null };
|
||||
}
|
||||
}
|
||||
|
||||
129
packages/widgets/src/app/test/serverData.spec.ts
Normal file
129
packages/widgets/src/app/test/serverData.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
import type { WidgetProps } from "../../definition";
|
||||
import getServerDataAsync from "../serverData";
|
||||
|
||||
const mockApp = (override: Partial<RouterOutputs["app"]["byId"]>) =>
|
||||
({
|
||||
id: "1",
|
||||
name: "Mock app",
|
||||
iconUrl: "https://some.com/icon.png",
|
||||
description: null,
|
||||
href: "https://google.ch",
|
||||
...override,
|
||||
}) satisfies RouterOutputs["app"]["byId"];
|
||||
|
||||
vi.mock("@homarr/api/server", () => ({
|
||||
api: {
|
||||
app: {
|
||||
byId: () => null,
|
||||
},
|
||||
widget: {
|
||||
app: {
|
||||
ping: () => null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getServerDataAsync should load app and ping result", () => {
|
||||
test("when appId is empty it should return null for app and pingResult", async () => {
|
||||
// Arrange
|
||||
const options = {
|
||||
appId: "",
|
||||
pingEnabled: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
|
||||
|
||||
// Assert
|
||||
expect(result.app).toBeNull();
|
||||
expect(result.pingResult).toBeNull();
|
||||
});
|
||||
|
||||
test("when app exists and ping is disabled it should return existing app and pingResult null", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(api.app, "byId");
|
||||
const options = {
|
||||
appId: "1",
|
||||
pingEnabled: false,
|
||||
};
|
||||
const mockedApp = mockApp({});
|
||||
spy.mockImplementation(() => Promise.resolve(mockedApp));
|
||||
|
||||
// Act
|
||||
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
|
||||
|
||||
// Assert
|
||||
expect(result.pingResult).toBeNull();
|
||||
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
|
||||
});
|
||||
|
||||
test("when app exists without href and ping enabled it should return existing app and pingResult null", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(api.app, "byId");
|
||||
const options = {
|
||||
appId: "1",
|
||||
pingEnabled: true,
|
||||
};
|
||||
const mockedApp = mockApp({ href: null });
|
||||
spy.mockImplementation(() => Promise.resolve(mockedApp));
|
||||
|
||||
// Act
|
||||
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
|
||||
|
||||
// Assert
|
||||
expect(result.pingResult).toBeNull();
|
||||
objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key]));
|
||||
});
|
||||
|
||||
test("when app does not exist it should return for both null", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(api.app, "byId");
|
||||
const options = {
|
||||
appId: "1",
|
||||
pingEnabled: true,
|
||||
};
|
||||
spy.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
|
||||
|
||||
// Assert
|
||||
expect(result.pingResult).toBeNull();
|
||||
expect(result.app).toBeNull();
|
||||
});
|
||||
|
||||
test("when app found and ping enabled it should return existing app and pingResult", async () => {
|
||||
// Arrange
|
||||
const spyById = vi.spyOn(api.app, "byId");
|
||||
const spyPing = vi.spyOn(api.widget.app, "ping");
|
||||
const options = {
|
||||
appId: "1",
|
||||
pingEnabled: true,
|
||||
};
|
||||
const mockedApp = mockApp({});
|
||||
const pingResult = { statusCode: 200, url: "http://localhost" };
|
||||
spyById.mockImplementation(() => Promise.resolve(mockedApp));
|
||||
spyPing.mockImplementation(() => Promise.resolve(pingResult));
|
||||
|
||||
// Act
|
||||
const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">);
|
||||
|
||||
// Assert
|
||||
expect(result.pingResult).toBe(pingResult);
|
||||
expect(result.app).toBe(mockedApp);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user