feat: add Umami Analytics integration and widget (#5362)
Co-authored-by: Josh G <febrile42@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com> Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
27 files changed
+1713
-2
No files matched your search
@@ -12,6 +12,9 @@ import { WidgetSliderInput } from "./widget-slider-input";
|
||||
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
|
||||
import { WidgetSwitchInput } from "./widget-switch-input";
|
||||
import { WidgetTextInput } from "./widget-text-input";
|
||||
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
|
||||
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
||||
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
||||
|
||||
const mapping = {
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
@@ -27,6 +30,9 @@ const mapping = {
|
||||
sortableItemList: WidgetSortedItemListInput,
|
||||
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
|
||||
dynamicSelect: WidgetDynamicSelectInput,
|
||||
umamiEventName: WidgetUmamiEventInput,
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Select, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
|
||||
const t = useScopedI18n("widget.umami.option.eventName");
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
|
||||
const integrationIds = form.values.integrationIds;
|
||||
const websiteId = form.values.options.websiteId as string | undefined;
|
||||
|
||||
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
|
||||
);
|
||||
|
||||
if (!websiteId || integrationIds.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("configureWebsiteFirst")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const data = [{ value: "", label: t("none") }, ...(eventNames ?? [])];
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={tInput("label")}
|
||||
description={tInput("description")}
|
||||
placeholder={isPending ? t("loading") : undefined}
|
||||
searchable
|
||||
nothingFoundMessage={t("noEvents")}
|
||||
data={data}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { MultiSelect, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
|
||||
const t = useScopedI18n("widget.umami.option.eventNames");
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
|
||||
const integrationIds = form.values.integrationIds;
|
||||
const websiteId = form.values.options.websiteId as string | undefined;
|
||||
|
||||
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
|
||||
);
|
||||
|
||||
if (!websiteId || integrationIds.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("configureWebsiteFirst")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
label={tInput("label")}
|
||||
description={tInput("description")}
|
||||
placeholder={isPending ? t("loading") : t("placeholder")}
|
||||
searchable
|
||||
nothingFoundMessage={t("noEvents")}
|
||||
data={eventNames ?? []}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Select, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
|
||||
const t = useScopedI18n("widget.umami.option.websiteId");
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
|
||||
const integrationIds = form.values.integrationIds;
|
||||
|
||||
const { data: websites, isPending } = clientApi.widget.umami.getWebsites.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) },
|
||||
);
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("configureIntegrationFirst")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={tInput("label")}
|
||||
description={tInput("description")}
|
||||
placeholder={isPending ? t("loading") : t("placeholder")}
|
||||
clearable
|
||||
searchable
|
||||
nothingFoundMessage={t("noWebsites")}
|
||||
data={(websites ?? []).map((site) => ({ value: site.id, label: site.domain }))}
|
||||
{...form.getInputProps(`options.${property}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -46,6 +46,7 @@ import * as systemDisks from "./system-disks";
|
||||
import * as systemResources from "./system-resources";
|
||||
import * as timetable from "./timetable";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
|
||||
@@ -91,6 +92,7 @@ export const widgetImports = {
|
||||
"immich-albumCarousel": immichAlbumCarousel,
|
||||
tracearr,
|
||||
speedtestTracker,
|
||||
umami,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -160,6 +160,21 @@ const optionsFactory = {
|
||||
defaultValue: "",
|
||||
withDescription: false,
|
||||
}),
|
||||
umamiEventName: () => ({
|
||||
type: "umamiEventName" as const,
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
umamiEventNames: () => ({
|
||||
type: "umamiEventNames" as const,
|
||||
defaultValue: [] as string[],
|
||||
withDescription: true,
|
||||
}),
|
||||
umamiWebsite: () => ({
|
||||
type: "umamiWebsite" as const,
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
|
||||
input: SortableItemListInput<TItem, TOptionValue>,
|
||||
) => ({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { UmamiContent } from "./umami-content";
|
||||
|
||||
export default function UmamiWidget({ options, integrationIds }: WidgetComponentProps<"umami">) {
|
||||
if (!options.websiteId || options.websiteId.trim() === "") {
|
||||
return <NoWebsiteConfigured />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UmamiContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={options.websiteId}
|
||||
timeFrame={options.timeFrame}
|
||||
eventName={options.eventName || undefined}
|
||||
eventNames={options.eventNames}
|
||||
chartStyle={options.chartStyle}
|
||||
chartType={options.chartType}
|
||||
viewMode={options.viewMode}
|
||||
topCount={options.topCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NoWebsiteConfigured() {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("error.noWebsite")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth"] as const;
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("umami", {
|
||||
icon: IconChartBar,
|
||||
supportedIntegrations: ["umami"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
websiteId: factory.umamiWebsite(),
|
||||
timeFrame: factory.select({
|
||||
options: timeFrameValues.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.umami.option.timeFrame.option.${value}`),
|
||||
})),
|
||||
defaultValue: "24h",
|
||||
withDescription: true,
|
||||
}),
|
||||
viewMode: factory.select({
|
||||
options: [
|
||||
{ value: "chart", label: (t) => t("widget.umami.option.viewMode.option.chart") },
|
||||
{ value: "events", label: (t) => t("widget.umami.option.viewMode.option.events") },
|
||||
{ value: "topPages", label: (t) => t("widget.umami.option.viewMode.option.topPages") },
|
||||
{ value: "topReferrers", label: (t) => t("widget.umami.option.viewMode.option.topReferrers") },
|
||||
] as const,
|
||||
defaultValue: "chart",
|
||||
withDescription: true,
|
||||
}),
|
||||
chartType: factory.select({
|
||||
options: [
|
||||
{ value: "bar", label: (t) => t("widget.umami.option.chartType.option.bar") },
|
||||
{ value: "sparkline", label: (t) => t("widget.umami.option.chartType.option.sparkline") },
|
||||
] as const,
|
||||
defaultValue: "bar",
|
||||
withDescription: true,
|
||||
}),
|
||||
chartStyle: factory.select({
|
||||
options: [
|
||||
{ value: "grouped", label: (t) => t("widget.umami.option.chartStyle.option.grouped") },
|
||||
{ value: "overlay", label: (t) => t("widget.umami.option.chartStyle.option.overlay") },
|
||||
] as const,
|
||||
defaultValue: "grouped",
|
||||
withDescription: true,
|
||||
}),
|
||||
eventName: factory.umamiEventName(),
|
||||
eventNames: factory.umamiEventNames(),
|
||||
topCount: factory.number({
|
||||
validate: z.number().int().min(1).max(500),
|
||||
defaultValue: 5,
|
||||
step: 1,
|
||||
withDescription: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
viewMode: {
|
||||
shouldHide: (options) => !options.websiteId,
|
||||
},
|
||||
chartType: {
|
||||
shouldHide: (options) =>
|
||||
!options.websiteId || (options.viewMode !== "chart" && options.viewMode !== "events"),
|
||||
},
|
||||
chartStyle: {
|
||||
shouldHide: (options) => options.viewMode !== "chart" || !options.eventName || options.chartType !== "bar",
|
||||
},
|
||||
eventName: {
|
||||
shouldHide: (options) => !options.websiteId || options.viewMode !== "chart",
|
||||
},
|
||||
eventNames: {
|
||||
shouldHide: (options) => !options.websiteId || options.viewMode !== "events",
|
||||
},
|
||||
topCount: {
|
||||
shouldHide: (options) => !options.websiteId || options.viewMode === "chart" || options.viewMode === "events",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.umami.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatDuration } from "@homarr/common";
|
||||
import type { UmamiVisitorStats } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UmamiEventsContent } from "./umami-events-content";
|
||||
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
|
||||
import { formatTimeFrameLabel, formatXLabel } from "./umami-utils";
|
||||
import type { TimeFrame } from "./umami-utils";
|
||||
|
||||
interface UmamiContentProps {
|
||||
integrationIds: string[];
|
||||
websiteId: string;
|
||||
timeFrame: TimeFrame;
|
||||
eventName?: string;
|
||||
eventNames: string[];
|
||||
chartStyle: string;
|
||||
chartType: string;
|
||||
viewMode: string;
|
||||
topCount: number;
|
||||
}
|
||||
|
||||
export function UmamiContent({
|
||||
integrationIds,
|
||||
websiteId,
|
||||
timeFrame,
|
||||
eventName,
|
||||
eventNames,
|
||||
chartStyle,
|
||||
chartType,
|
||||
viewMode,
|
||||
topCount,
|
||||
}: UmamiContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||
|
||||
const [results] = clientApi.widget.umami.getVisitorStats.useSuspenseQuery({
|
||||
integrationIds,
|
||||
websiteId,
|
||||
timeFrame,
|
||||
eventName,
|
||||
});
|
||||
|
||||
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
|
||||
const [activeVisitors] = clientApi.widget.umami.getActiveVisitors.useSuspenseQuery(activeVisitorsInput);
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
|
||||
onData(count) {
|
||||
utils.widget.umami.getActiveVisitors.setData(activeVisitorsInput, () => count);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].sort() },
|
||||
{ enabled: viewMode === "events" && eventNames.length > 0 },
|
||||
);
|
||||
|
||||
const multiEventTotal = multiEventSeries
|
||||
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
|
||||
: undefined;
|
||||
|
||||
const firstResult = results[0];
|
||||
if (!firstResult) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("error.noData")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const { visitorStats } = firstResult;
|
||||
|
||||
const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({
|
||||
label: formatXLabel(point.timestamp, timeFrame),
|
||||
visitors: point.visitors,
|
||||
...(point.events !== undefined ? { events: point.events } : {}),
|
||||
}));
|
||||
|
||||
const hasEventSeries = visitorStats.dataPoints.some(({ events }) => events !== undefined);
|
||||
const series = [
|
||||
{ name: "visitors", color: "blue.5" },
|
||||
...(hasEventSeries ? [{ name: "events", color: "orange.5" }] : []),
|
||||
];
|
||||
|
||||
const isOverlay = chartStyle === "overlay" && hasEventSeries;
|
||||
const overlayBarSize = 16;
|
||||
|
||||
return (
|
||||
<Stack gap={4} p="xs" h="100%">
|
||||
<Group justify="space-between" align="baseline" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
|
||||
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
|
||||
</Text>
|
||||
{(eventName && viewMode === "chart" && visitorStats.eventCount !== undefined) ||
|
||||
(viewMode === "events" && multiEventTotal !== undefined) ? (
|
||||
<Group gap={6} align="baseline" wrap="nowrap">
|
||||
<Group gap={4} align="baseline">
|
||||
<Text size="lg" fw={700} lh={1}>
|
||||
{viewMode === "events"
|
||||
? (multiEventTotal ?? 0).toLocaleString()
|
||||
: (visitorStats.eventCount ?? 0).toLocaleString()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("event.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Group gap={4} align="baseline">
|
||||
<Text size="lg" fw={700} lh={1}>
|
||||
{visitorStats.totalVisitors.toLocaleString()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("visitors")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={4} align="baseline">
|
||||
<Text size="xl" fw={700} lh={1}>
|
||||
{visitorStats.totalVisitors.toLocaleString()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("visitors")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
|
||||
<Text size="xs" c="green">
|
||||
● {activeVisitors.toLocaleString()} {t("active")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.bounceRate}% {t("bounceRate")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
{viewMode === "events" ? (
|
||||
<UmamiEventsContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
eventNames={eventNames}
|
||||
chartType={chartType}
|
||||
/>
|
||||
) : viewMode === "topPages" ? (
|
||||
<UmamiTopPagesContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
) : viewMode === "topReferrers" ? (
|
||||
<UmamiTopReferrersContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
) : chartType === "sparkline" ? (
|
||||
<LineChart
|
||||
h="100%"
|
||||
data={chartData}
|
||||
dataKey="label"
|
||||
series={series}
|
||||
withDots={false}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withYAxis={false}
|
||||
xAxisProps={{
|
||||
tick: { fontSize: 9, fill: tickColor },
|
||||
interval: "preserveStartEnd",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<BarChart
|
||||
h="100%"
|
||||
data={chartData}
|
||||
dataKey="label"
|
||||
series={series}
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withYAxis={false}
|
||||
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
|
||||
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
|
||||
xAxisProps={{
|
||||
tick: { fontSize: 9, fill: tickColor },
|
||||
interval: "preserveStartEnd",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UmamiEventSeries } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
|
||||
|
||||
interface UmamiEventsContentProps {
|
||||
integrationIds: string[];
|
||||
websiteId: string;
|
||||
timeFrame: string;
|
||||
eventNames: string[];
|
||||
chartType: string;
|
||||
}
|
||||
|
||||
export function UmamiEventsContent({
|
||||
integrationIds,
|
||||
websiteId,
|
||||
timeFrame,
|
||||
eventNames,
|
||||
chartType,
|
||||
}: UmamiEventsContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||
|
||||
const [series] = clientApi.widget.umami.getMultiEventTimeSeries.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
websiteId,
|
||||
timeFrame,
|
||||
eventNames: [...eventNames].sort(),
|
||||
});
|
||||
|
||||
if (eventNames.length === 0) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("events.selectPrompt")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all unique timestamps across all series
|
||||
const allTimestamps = Array.from(
|
||||
new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))),
|
||||
).sort();
|
||||
|
||||
// Build per-event lookup by timestamp string
|
||||
const byEvent = new Map(
|
||||
series.map((serie: UmamiEventSeries) => [
|
||||
serie.eventName,
|
||||
new Map(serie.dataPoints.map((point) => [point.x, point.y])),
|
||||
]),
|
||||
);
|
||||
|
||||
const chartData = allTimestamps.map((timestamp) => {
|
||||
const row: Record<string, string | number> = { label: formatXLabel(timestamp, timeFrame) };
|
||||
for (const serie of series) {
|
||||
row[serie.eventName] = byEvent.get(serie.eventName)?.get(timestamp) ?? 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const chartSeries = series.map((serie: UmamiEventSeries, index: number) => ({
|
||||
name: serie.eventName,
|
||||
color: EVENT_COLORS[index % EVENT_COLORS.length] ?? "blue.5",
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
{chartSeries.length > 1 && (
|
||||
<Group gap={12} justify="center" wrap="wrap" style={{ flexShrink: 0, rowGap: 2 }}>
|
||||
{chartSeries.map((serie) => (
|
||||
<Group key={serie.name} gap={4} align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
backgroundColor: `var(--mantine-color-${serie.color.replace(".", "-")})`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{serie.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
{chartType === "sparkline" ? (
|
||||
<LineChart
|
||||
h="100%"
|
||||
data={chartData}
|
||||
dataKey="label"
|
||||
series={chartSeries}
|
||||
withDots={false}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withYAxis={false}
|
||||
xAxisProps={{
|
||||
tick: { fontSize: 9, fill: tickColor },
|
||||
interval: "preserveStartEnd",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<BarChart
|
||||
h="100%"
|
||||
data={chartData}
|
||||
dataKey="label"
|
||||
series={chartSeries}
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
withTooltip
|
||||
withXAxis
|
||||
withYAxis={false}
|
||||
barProps={{ radius: 2 }}
|
||||
xAxisProps={{
|
||||
tick: { fontSize: 9, fill: tickColor },
|
||||
interval: "preserveStartEnd",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UmamiMetricItem } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UmamiTopListProps {
|
||||
integrationIds: string[];
|
||||
websiteId: string;
|
||||
timeFrame: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const [data] = clientApi.widget.umami.getTopPages.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
websiteId,
|
||||
timeFrame,
|
||||
limit,
|
||||
});
|
||||
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
|
||||
}
|
||||
|
||||
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const [data] = clientApi.widget.umami.getTopReferrers.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
websiteId,
|
||||
timeFrame,
|
||||
limit,
|
||||
});
|
||||
return (
|
||||
<UmamiTopList
|
||||
items={data}
|
||||
heading={t("option.viewMode.option.topReferrers")}
|
||||
emptyLabel={t("topReferrers.direct")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UmamiTopList({
|
||||
items,
|
||||
heading,
|
||||
emptyLabel,
|
||||
}: {
|
||||
items: UmamiMetricItem[];
|
||||
heading: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} h="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{heading}
|
||||
</Text>
|
||||
<ScrollArea style={{ flex: 1 }} scrollbars="y">
|
||||
<Stack gap={2}>
|
||||
{items.map((item, i) => (
|
||||
<Group key={item.x} gap="xs" wrap="nowrap" px={2}>
|
||||
<Text size="xs" c="dimmed" w={18} ta="right" flex="0 0 auto">
|
||||
{i + 1}.
|
||||
</Text>
|
||||
<Text size="xs" truncate="end" style={{ flex: 1 }}>
|
||||
{item.x || emptyLabel}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} flex="0 0 auto">
|
||||
{item.y.toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { timeFrameValues } from "./index";
|
||||
|
||||
export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];
|
||||
|
||||
export function formatXLabel(timestamp: string, timeFrame: string): string {
|
||||
const date = new Date(timestamp);
|
||||
switch (timeFrame) {
|
||||
case "today":
|
||||
case "24h":
|
||||
return `${date.getHours().toString().padStart(2, "0")}:00`;
|
||||
case "7d":
|
||||
case "30d":
|
||||
case "month":
|
||||
case "lastMonth":
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
default:
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
export type TimeFrame = (typeof timeFrameValues)[number];
|
||||
|
||||
export function formatTimeFrameLabel(
|
||||
timeFrame: TimeFrame,
|
||||
t: ReturnType<typeof useScopedI18n<"widget.umami">>,
|
||||
): string {
|
||||
if (timeFrameValues.includes(timeFrame)) {
|
||||
return t(`option.timeFrame.option.${timeFrame}`);
|
||||
}
|
||||
return timeFrame;
|
||||
}
|
||||
Reference in new issue
Block a user