diff --git a/.gitignore b/.gitignore index 43488df7a..0e991cc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,4 @@ en.d.json.ts # personal agent config AGENTS.md -.opencode \ No newline at end of file +.opencode diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 5e29dfa72..e40c3f000 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -30,4 +30,5 @@ export const widgetRouter = createTRPCRouter({ timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), + umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), }); diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts new file mode 100644 index 000000000..2c240f4bb --- /dev/null +++ b/packages/api/src/router/widgets/umami.ts @@ -0,0 +1,205 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { createIntegrationAsync } from "@homarr/integrations"; +import { + umamiActiveVisitorsRequestHandler, + umamiEventNamesRequestHandler, + umamiMultiEventRequestHandler, + umamiRequestHandler, + umamiTopPagesRequestHandler, + umamiTopReferrersRequestHandler, +} from "@homarr/request-handler/umami"; + +import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const logger = createLogger({ module: "umami-router" }); + +export const umamiRouter = createTRPCRouter({ + getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => { + try { + const instance = await createIntegrationAsync(ctx.integration); + return await instance.getWebsitesAsync(); + } catch (error) { + logger.warn(new Error("Failed to load websites", { cause: error })); + return []; + } + }), + + getVisitorStats: publicProcedure + .input( + z.object({ + websiteId: z.string(), + timeFrame: z.string(), + eventName: z.string().optional(), + }), + ) + .concat(createManyIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = umamiRequestHandler.handler(integration, { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + eventName: input.eventName, + }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + visitorStats: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + + getEventNames: publicProcedure + .input(z.object({ websiteId: z.string() })) + .concat(createOneIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + try { + const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + } catch (error) { + logger.warn( + new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }), + ); + return []; + } + }), + + getTopPages: publicProcedure + .input( + z.object({ + websiteId: z.string(), + timeFrame: z.string(), + limit: z.number().int().min(1).max(500), + }), + ) + .concat(createOneIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + try { + const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + limit: input.limit, + }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + } catch (error) { + logger.warn( + new ErrorWithMetadata( + "Failed to load top pages", + { websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit }, + { cause: error }, + ), + ); + return []; + } + }), + + getTopReferrers: publicProcedure + .input( + z.object({ + websiteId: z.string(), + timeFrame: z.string(), + limit: z.number().int().min(1).max(500), + }), + ) + .concat(createOneIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + try { + const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + limit: input.limit, + }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + } catch (error) { + logger.warn( + new ErrorWithMetadata( + "Failed to load top referrers", + { websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit }, + { cause: error }, + ), + ); + return []; + } + }), + + getMultiEventTimeSeries: publicProcedure + .input( + z.object({ + websiteId: z.string(), + timeFrame: z.string(), + eventNames: z.array(z.string()), + }), + ) + .concat(createOneIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + try { + const sortedNames = [...input.eventNames].sort(); + const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, { + websiteId: input.websiteId, + timeFrame: input.timeFrame, + eventNames: sortedNames, + }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + } catch (error) { + logger.warn( + new ErrorWithMetadata( + "Failed to load multi-event time series", + { websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) }, + { cause: error }, + ), + ); + return []; + } + }), + + getActiveVisitors: publicProcedure + .input(z.object({ websiteId: z.string() })) + .concat(createOneIntegrationMiddleware("query", "umami")) + .query(async ({ ctx, input }) => { + try { + const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { + websiteId: input.websiteId, + }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + } catch (error) { + logger.warn( + new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }), + ); + return 0; + } + }), + + subscribeActiveVisitors: publicProcedure + .input(z.object({ websiteId: z.string() })) + .concat(createOneIntegrationMiddleware("query", "umami")) + .subscription(({ ctx, input }) => { + return observable((emit) => { + const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { + websiteId: input.websiteId, + }); + const unsubscribe = innerHandler.subscribe((count) => { + emit.next(count); + }); + return () => { + unsubscribe(); + }; + }); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 7f2b2349f..685fcb448 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -20,6 +20,7 @@ import { networkControllerJob } from "./jobs/integrations/network-controller"; import { refreshNotificationsJob } from "./jobs/integrations/notifications"; import { timetableJob } from "./jobs/integrations/timetable"; import { tracearrJob } from "./jobs/integrations/tracearr"; +import { umamiActiveVisitorsJob } from "./jobs/integrations/umami"; import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -54,6 +55,7 @@ export const jobGroup = createCronJobGroup({ weather: weatherJob, timetable: timetableJob, tracearr: tracearrJob, + umamiActiveVisitors: umamiActiveVisitorsJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/umami.ts b/packages/cron-jobs/src/jobs/integrations/umami.ts new file mode 100644 index 000000000..c50a3c604 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/umami.ts @@ -0,0 +1,14 @@ +import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami"; + +import { createCronJob } from "../../lib"; + +export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback( + createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, { + widgetKinds: ["umami"], + getInput: { + umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []), + }, + }), +); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 407c03746..a436b19c0 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -4,6 +4,7 @@ import { everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, + integrationDefs, integrationKinds, } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -143,8 +144,9 @@ const seedDefaultIntegrationsAsync = async (db: Database) => { const defaultIntegrations = integrationKinds.reduce((acc, kind) => { const name = getIntegrationName(kind); const defaultUrl = getIntegrationDefaultUrl(kind); + const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0); - if (defaultUrl !== undefined) { + if (defaultUrl !== undefined && hasNoAuthOption) { acc.push({ id: "new", name: `${name} Default`, diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 006bc9ab7..81c58b0af 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -370,6 +370,15 @@ export const integrationDefs = { category: ["speedtest"], documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), }, + umami: { + name: "Umami", + secretKinds: [["username", "password"], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg", + category: ["analytics"], + defaultUrl: "https://api.umami.is/v1", + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/umami"), + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", @@ -461,6 +470,7 @@ export const integrationCategories = [ "notes", "mediaMonitoring", "speedtest", + "analytics", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index b246b32ea..d6305a08e 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -37,5 +37,6 @@ export const widgetKinds = [ "immich-albumCarousel", "tracearr", "speedtestTracker", + "umami", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 5748bdd96..9e2466bea 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -46,6 +46,7 @@ import { SeerrIntegration } from "../seerr/seerr-integration"; import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; +import { UmamiIntegration } from "../umami/umami-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -121,6 +122,7 @@ export const integrationCreators = { searchCh: SearchChIntegration, immich: ImmichIntegration, speedtestTracker: SpeedtestTrackerIntegration, + umami: UmamiIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 2ac590bdb..c7451265c 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -33,6 +33,7 @@ export { CoolifyIntegration } from "./coolify/coolify-integration"; export { ImmichIntegration } from "./immich/immich-integration"; export { TracearrIntegration } from "./tracearr/tracearr-integration"; export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration"; +export { UmamiIntegration } from "./umami/umami-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -68,6 +69,7 @@ export type { } from "./anchor/anchor-types"; export type { TracearrDashboardData } from "./tracearr/tracearr-types"; export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types"; +export type { UmamiVisitorStats } from "./umami/umami-types"; // Schemas export { anchorNotesListInputSchema } from "./anchor/anchor-types"; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index eb8b9e6ca..10ae96ee4 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -16,3 +16,4 @@ export * from "./anchor/anchor-types"; export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; +export * from "./umami/umami-types"; diff --git a/packages/integrations/src/umami/umami-integration.ts b/packages/integrations/src/umami/umami-integration.ts new file mode 100644 index 000000000..730cd5477 --- /dev/null +++ b/packages/integrations/src/umami/umami-integration.ts @@ -0,0 +1,450 @@ +import dayjs from "dayjs"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + UmamiEventSeries, + UmamiMetricItem, + UmamiPageviewDataPoint, + UmamiPageviews, + UmamiStats, + UmamiVisitorStats, + UmamiWebsite, +} from "./umami-types"; +import { + umamiActiveVisitorsSchema, + umamiAuthResponseSchema, + umamiMetricItemSchema, + umamiPageviewDataPointSchema, + umamiPageviewsSchema, + umamiStatsSchema, + umamiWebsiteSchema, +} from "./umami-types"; + +// Umami event type ID for custom events (type 1 = page view, type 2 = custom event) +const UMAMI_CUSTOM_EVENT_TYPE = 2; + +const logger = createLogger({ module: "umami-integration" }); + +export class UmamiIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const url = this.url("/websites"); + const response = await input.fetchAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { success: true }; + } + + public async getWebsitesAsync(): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const url = this.url("/websites"); + const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders }); + if (!response.ok) throw new ResponseError(response); + const json = await response.json(); + // Response envelope differs by hosting: + // - Umami self-hosted returns the array directly: [website, ...] + // - Umami Cloud wraps it: { data: [website, ...] } + // Same divergence applies to the /events list responses below. + return Array.isArray(json) + ? umamiWebsiteSchema.array().parse(json) + : umamiWebsiteSchema.array().parse((json as { data: unknown }).data); + } + + // Server-side timeFrame resolution is intentional. Keeping `timeFrame` (a + // stable string like "7d") in the cache key means all users viewing the same + // dashboard share one Redis cache entry per (integration, website, timeFrame) + // for the 5-minute TTL. Client-supplied startAt/endAt would include a + // per-second `now` and fragment the cache one-per-user-per-second. + // + // Window bounds mirror Umami UI preset semantics, verified against the + // Umami Cloud dashboard for the same site and timeFrame: each "Last N" + // preset includes the in-progress period as one of N + 1 buckets, snapped + // to bucket boundaries. + // - "24h": current hour + 24 prior hours (25-bucket hour grid) + // - "7d": current day + 7 prior days ( 8-bucket day grid) + // - "30d": current day + 30 prior days (31-bucket day grid) + // - "today" / "month" / "lastMonth": calendar-aligned windows. + // + // Caveat: dayjs() uses the server's local timezone. Callers needing user-local + // boundaries (e.g. "today") may see off-by-one behaviour when the server + // runs in UTC but the user is in a different timezone. + private computeTimeRange(timeFrame: string): { startAt: number; endAt: number; unit: string } { + const startOfToday = dayjs().startOf("day"); + const endOfToday = dayjs().endOf("day"); + + switch (timeFrame) { + case "today": + return { startAt: startOfToday.valueOf(), endAt: endOfToday.valueOf(), unit: "hour" }; + case "7d": + return { + startAt: startOfToday.subtract(7, "day").valueOf(), + endAt: endOfToday.valueOf(), + unit: "day", + }; + case "30d": + return { + startAt: startOfToday.subtract(30, "day").valueOf(), + endAt: endOfToday.valueOf(), + unit: "day", + }; + case "month": + return { + startAt: dayjs().startOf("month").valueOf(), + endAt: dayjs().endOf("month").valueOf(), + unit: "day", + }; + case "lastMonth": { + const lastMonth = dayjs().subtract(1, "month"); + return { + startAt: lastMonth.startOf("month").valueOf(), + endAt: lastMonth.endOf("month").valueOf(), + unit: "day", + }; + } + case "24h": + default: + return { + startAt: dayjs().subtract(24, "hour").startOf("hour").valueOf(), + endAt: dayjs().endOf("hour").valueOf(), + unit: "hour", + }; + } + } + + public async getVisitorStatsAsync( + websiteId: string, + timeFrame = "24h", + eventName?: string, + ): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const website = await this.getWebsiteByIdAsync(websiteId, authHeaders); + + const { startAt, endAt, unit } = this.computeTimeRange(timeFrame); + + const [stats, pageviews, eventMetrics, eventTimeSeries] = await Promise.all([ + this.getWebsiteStatsAsync(website.id, startAt, endAt, authHeaders), + this.getWebsitePageviewsAsync(website.id, startAt, endAt, authHeaders, unit), + eventName ? this.getWebsiteEventMetricsAsync(website.id, startAt, endAt, authHeaders) : Promise.resolve(null), + eventName + ? this.getWebsiteEventTimeSeriesAsync(website.id, startAt, endAt, unit, eventName, authHeaders) + : Promise.resolve(null), + ]); + + const eventCount = eventName && eventMetrics ? (eventMetrics.find(({ x }) => x === eventName)?.y ?? 0) : undefined; + + const eventByTimestamp = new Map( + (eventTimeSeries ?? []).map(({ x, y }) => [this.parseUmamiDate(x), y]), + ); + const dataPoints = pageviews.sessions.map((point) => ({ + timestamp: point.x, + visitors: point.y, + ...(eventTimeSeries ? { events: eventByTimestamp.get(this.parseUmamiDate(point.x)) ?? 0 } : {}), + })); + + return { + domain: website.domain, + websiteName: website.name, + websiteId: website.id, + totalVisitors: stats.visitors, + totalPageviews: stats.pageviews, + totalVisits: stats.visits, + bounceRate: stats.visits > 0 ? Math.round((stats.bounces / stats.visits) * 100) : 0, + avgDuration: stats.visits > 0 ? Math.round(stats.totaltime / stats.visits) : 0, + dataPoints, + timeFrame, + eventCount, + }; + } + + public async getEventNamesAsync(websiteId: string): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const now = Date.now(); + const startAt = dayjs().subtract(30, "day").valueOf(); + const url = this.url(`/websites/${websiteId}/events`, { + startAt: startAt.toString(), + endAt: now.toString(), + pageSize: "100000", + }); + const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders }); + if (!response.ok) { + logger.warn("Failed to load event names", { status: response.status, websiteId }); + return []; + } + const json = await response.json(); + const rawArray: unknown[] = Array.isArray(json) + ? json + : json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data) + ? (json as { data: unknown[] }).data + : []; + const names = new Set(); + for (const item of rawArray) { + const record = item as Record; + if (typeof record.eventName === "string" && record.eventName) { + names.add(record.eventName); + } + } + return [...names].sort(); + } + + public async getActiveVisitorsAsync(websiteId: string): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const url = this.url(`/websites/${websiteId}/active`); + const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders }); + if (!response.ok) { + logger.warn("Failed to load active visitors", { status: response.status, websiteId }); + return 0; + } + return umamiActiveVisitorsSchema.parse(await response.json()).visitors; + } + + /** + * Umami self-hosted returns "YYYY-MM-DD HH:MM:SS"; Umami Cloud returns ISO 8601. + * Normalise both to epoch ms so timestamps from /pageviews and /events join. + */ + private parseUmamiDate(dateString: string): number { + return new Date(dateString.includes("T") ? dateString : `${dateString.replace(" ", "T")}Z`).getTime(); + } + + private async getWebsiteByIdAsync(websiteId: string, authHeaders: Record): Promise { + const url = this.url(`/websites/${websiteId}`); + const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders }); + if (!response.ok) throw new ResponseError(response); + return umamiWebsiteSchema.parse(await response.json()); + } + + public async getTopPagesAsync(websiteId: string, timeFrame: string, limit: number): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const { startAt, endAt } = this.computeTimeRange(timeFrame); + return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "path", authHeaders, limit); + } + + public async getTopReferrersAsync(websiteId: string, timeFrame: string, limit: number): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const { startAt, endAt } = this.computeTimeRange(timeFrame); + return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "referrer", authHeaders, limit); + } + + public async getMultiEventTimeSeriesAsync( + websiteId: string, + timeFrame: string, + eventNames: string[], + ): Promise { + const authHeaders = await this.getAuthHeadersAsync(); + const { startAt, endAt, unit } = this.computeTimeRange(timeFrame); + const results = await Promise.all( + eventNames.map(async (eventName) => { + const dataPoints = await this.getWebsiteEventTimeSeriesAsync( + websiteId, + startAt, + endAt, + unit, + eventName, + authHeaders, + ); + return { eventName, dataPoints: dataPoints ?? [] }; + }), + ); + return results; + } + + private async getWebsiteEventMetricsAsync( + websiteId: string, + startAt: number, + endAt: number, + authHeaders: Record, + ): Promise { + return await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders); + } + + private async getWebsiteMetricsAsync( + websiteId: string, + startAt: number, + endAt: number, + type: "event" | "path" | "referrer", + authHeaders: Record, + limit?: number, + ): Promise { + const params: Record = { + startAt: startAt.toString(), + endAt: endAt.toString(), + type, + }; + if (limit !== undefined) params.limit = limit.toString(); + const url = this.url(`/websites/${websiteId}/metrics`, params); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + logger.warn("Failed to load website metrics", { status: response.status, websiteId, type }); + return []; + } + + const json = await response.json(); + if (Array.isArray(json)) return umamiMetricItemSchema.array().parse(json); + logger.warn("Unexpected metrics response shape", { websiteId, type }); + return []; + } + + private async getWebsiteEventTimeSeriesAsync( + websiteId: string, + startAt: number, + endAt: number, + unit: string, + eventName: string, + authHeaders: Record, + ): Promise { + const url = this.url(`/websites/${websiteId}/events`, { + startAt: startAt.toString(), + endAt: endAt.toString(), + unit, + timezone: "UTC", + eventName, + pageSize: "100000", + }); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName }); + return undefined; + } + + const json = await response.json(); + const rawArray: unknown[] = Array.isArray(json) + ? json + : json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data) + ? (json as { data: unknown[] }).data + : []; + + if (rawArray.length === 0) return undefined; + + const firstItem = rawArray[0] as Record; + + // Two response shapes coexist: + // - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count } + // - Umami Cloud returns raw event records: { createdAt, eventName, eventType } + // We aggregate the raw Cloud records ourselves into the same bucket grid so + // the result joins cleanly with /pageviews timestamps. + + // Aggregated { x, y } format — parse directly (self-hosted instances) + if ("x" in firstItem && "y" in firstItem) { + const points = umamiPageviewDataPointSchema.array().parse(rawArray); + return points.length === 0 ? undefined : points; + } + + // Raw event records — aggregate by time bucket (Umami Cloud) + if ("createdAt" in firstItem) { + const pad = (number: number) => String(number).padStart(2, "0"); + // Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS" + const truncate = (isoDate: string): string => { + const date = new Date(isoDate); + const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; + if (unit === "hour") { + return `${dateString} ${pad(date.getUTCHours())}:00:00`; + } + if (unit === "month") { + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`; + } + return `${dateString} 00:00:00`; + }; + + const buckets = new Map(); + for (const item of rawArray) { + const event = item as { eventType?: number; eventName?: string; createdAt: string }; + if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue; + const bucket = truncate(event.createdAt); + buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1); + } + if (buckets.size === 0) return undefined; + return ( + Array.from(buckets.entries()) + .sort(([itemA], [itemB]) => itemA.localeCompare(itemB)) + // eslint-disable-next-line id-length + .map(([x, y]) => ({ x, y })) + ); + } + + return undefined; + } + + private async getWebsiteStatsAsync( + websiteId: string, + startAt: number, + endAt: number, + authHeaders: Record, + ): Promise { + const url = this.url(`/websites/${websiteId}/stats`, { + startAt: startAt.toString(), + endAt: endAt.toString(), + }); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return umamiStatsSchema.parse(await response.json()); + } + + private async getWebsitePageviewsAsync( + websiteId: string, + startAt: number, + endAt: number, + authHeaders: Record, + unit = "hour", + ): Promise { + const url = this.url(`/websites/${websiteId}/pageviews`, { + startAt: startAt.toString(), + endAt: endAt.toString(), + unit, + timezone: "UTC", + }); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: authHeaders, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return umamiPageviewsSchema.parse(await response.json()); + } + + private async getAuthHeadersAsync(): Promise> { + if (this.hasSecretValue("apiKey")) { + return { "x-umami-api-key": this.getSecretValue("apiKey") }; + } + const token = await this.getJwtTokenAsync(); + return { Authorization: `Bearer ${token}` }; + } + + private async getJwtTokenAsync(): Promise { + const url = this.url("/auth/login"); + const response = await fetchWithTrustedCertificatesAsync(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }), + }); + if (!response.ok) throw new ResponseError(response); + return umamiAuthResponseSchema.parse(await response.json()).token; + } +} diff --git a/packages/integrations/src/umami/umami-types.ts b/packages/integrations/src/umami/umami-types.ts new file mode 100644 index 000000000..4e38b2a2c --- /dev/null +++ b/packages/integrations/src/umami/umami-types.ts @@ -0,0 +1,84 @@ +import { z } from "zod/v4"; + +export const umamiWebsiteSchema = z.object({ + id: z.string(), + name: z.string(), + domain: z.string(), +}); + +export type UmamiWebsite = z.infer; + +export const umamiAuthResponseSchema = z.object({ + token: z.string(), +}); + +export type UmamiAuthResponse = z.infer; + +export const umamiStatsSchema = z.object({ + pageviews: z.number(), + visitors: z.number(), + visits: z.number(), + bounces: z.number(), + totaltime: z.number(), +}); + +export type UmamiStats = z.infer; + +export const umamiPageviewDataPointSchema = z.object({ + x: z.string(), + y: z.number(), +}); + +export type UmamiPageviewDataPoint = z.infer; + +export const umamiPageviewsSchema = z.object({ + pageviews: z.array(umamiPageviewDataPointSchema), + sessions: z.array(umamiPageviewDataPointSchema), +}); + +export type UmamiPageviews = z.infer; + +export const umamiMetricItemSchema = z.object({ + // x can be null for direct traffic (no referrer) in the referrers endpoint + x: z + .string() + .nullable() + .transform((value) => value ?? ""), + y: z.number(), +}); + +export type UmamiMetricItem = z.infer; + +export const umamiEventRecordSchema = z.object({ + id: z.string(), + eventName: z.string(), + createdAt: z.string(), +}); + +export type UmamiEventRecord = z.infer; + +export const umamiActiveVisitorsSchema = z.object({ + visitors: z.number(), +}); + +export type UmamiActiveVisitors = z.infer; + +// Internal aggregated type returned by the integration — not a direct API response +export interface UmamiEventSeries { + eventName: string; + dataPoints: UmamiPageviewDataPoint[]; +} + +export interface UmamiVisitorStats { + domain: string; + websiteId: string; + websiteName: string; + totalVisitors: number; + totalPageviews: number; + totalVisits: number; + bounceRate: number; + avgDuration: number; + dataPoints: { timestamp: string; visitors: number; events?: number }[]; + timeFrame: string; + eventCount?: number; +} diff --git a/packages/request-handler/src/umami.ts b/packages/request-handler/src/umami.ts new file mode 100644 index 000000000..e943c568a --- /dev/null +++ b/packages/request-handler/src/umami.ts @@ -0,0 +1,84 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const umamiRequestHandler = createCachedIntegrationRequestHandler< + UmamiVisitorStats, + "umami", + { websiteId: string; timeFrame: string; eventName?: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "umamiVisitorStats", +}); + +export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler< + string[], + "umami", + { websiteId: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getEventNamesAsync(input.websiteId); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "umamiEventNames", +}); + +export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler< + UmamiMetricItem[], + "umami", + { websiteId: string; timeFrame: string; limit: number } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "umamiTopPages", +}); + +export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler< + UmamiMetricItem[], + "umami", + { websiteId: string; timeFrame: string; limit: number } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "umamiTopReferrers", +}); + +export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler< + UmamiEventSeries[], + "umami", + { websiteId: string; timeFrame: string; eventNames: string[] } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "umamiMultiEvent", +}); + +export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler< + number, + "umami", + { websiteId: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getActiveVisitorsAsync(input.websiteId); + }, + cacheDuration: dayjs.duration(30, "seconds"), + queryKey: "umamiActiveVisitors", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 817abb3f8..cdde0511b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2096,6 +2096,104 @@ "internalServerError": "Failed to fetch Tracearr data" } }, + "umami": { + "name": "Umami Analytics", + "description": "Display visitor stats from your Umami analytics instance", + "visitors": "visitors", + "active": "active", + "pageviews": "pageviews", + "visits": "visits", + "bounceRate": "bounce", + "avgDuration": "avg", + "option": { + "websiteId": { + "label": "Website", + "description": "Select a website tracked by your Umami instance", + "placeholder": "Select a website", + "loading": "Loading websites...", + "noWebsites": "No websites found", + "configureIntegrationFirst": "Connect a Umami integration first" + }, + "timeFrame": { + "label": "Time frame", + "description": "The time period to display visitor data for", + "option": { + "today": "Today", + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + "month": "This month", + "lastMonth": "Last month" + } + }, + "eventName": { + "label": "Event", + "description": "Show total occurrences of a specific event", + "placeholder": "Select an event", + "loading": "Loading events...", + "noEvents": "No events found", + "none": "No event", + "configureWebsiteFirst": "Select a website first to load events" + }, + "chartType": { + "label": "Chart type", + "description": "Display style for the chart", + "option": { + "bar": "Bar chart", + "sparkline": "Sparkline" + } + }, + "chartStyle": { + "label": "Chart style", + "description": "How to display the event series in the chart", + "option": { + "grouped": "Side by side", + "overlay": "Overlay (subset)" + } + }, + "viewMode": { + "label": "View", + "description": "What to display in the widget body", + "option": { + "chart": "Chart", + "events": "Events chart", + "topPages": "Top pages", + "topReferrers": "Top referrers" + } + }, + "eventNames": { + "label": "Events", + "description": "Select one or more events to chart", + "placeholder": "Select events...", + "loading": "Loading events...", + "noEvents": "No events found", + "configureWebsiteFirst": "Select a website first to load events" + }, + "topCount": { + "label": "Items to show", + "description": "Number of top items to display (max 500)" + } + }, + "event": { + "occurrences": "events", + "label": "events" + }, + "topPages": { + "direct": "Direct" + }, + "topReferrers": { + "direct": "Direct" + }, + "events": { + "selectPrompt": "Select events to chart" + }, + "error": { + "noIntegration": "No Umami integration configured", + "noWebsite": "No website configured", + "noData": "No data available", + "internalServerError": "Failed to fetch Umami data" + } + }, "common": { "location": { "query": "City / Postal code", @@ -3668,6 +3766,9 @@ }, "tracearr": { "label": "Tracearr" + }, + "umamiActiveVisitors": { + "label": "Umami Active Visitors" } }, "interval": { diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 8917664de..0c412b74e 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -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; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-umami-event-input.tsx b/packages/widgets/src/_inputs/widget-umami-event-input.tsx new file mode 100644 index 000000000..947822cef --- /dev/null +++ b/packages/widgets/src/_inputs/widget-umami-event-input.tsx @@ -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 ( + + {t("configureWebsiteFirst")} + + ); + } + + const data = [{ value: "", label: t("none") }, ...(eventNames ?? [])]; + + return ( + ({ value: site.id, label: site.domain }))} + {...form.getInputProps(`options.${property}`)} + /> + ); +}; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index a59f93f42..572f70aa4 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index bc207db83..75b758b3d 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -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: ( input: SortableItemListInput, ) => ({ diff --git a/packages/widgets/src/umami/component.tsx b/packages/widgets/src/umami/component.tsx new file mode 100644 index 000000000..2aec3b46e --- /dev/null +++ b/packages/widgets/src/umami/component.tsx @@ -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 ; + } + + return ( + + ); +} + +function NoWebsiteConfigured() { + const t = useScopedI18n("widget.umami"); + return ( + + + {t("error.noWebsite")} + + + ); +} diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts new file mode 100644 index 000000000..c55ecd261 --- /dev/null +++ b/packages/widgets/src/umami/index.ts @@ -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")); diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx new file mode 100644 index 000000000..da9494519 --- /dev/null +++ b/packages/widgets/src/umami/umami-content.tsx @@ -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 ( + + + {t("error.noData")} + + + ); + } + + 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 ( + + + + {visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)}) + + {(eventName && viewMode === "chart" && visitorStats.eventCount !== undefined) || + (viewMode === "events" && multiEventTotal !== undefined) ? ( + + + + {viewMode === "events" + ? (multiEventTotal ?? 0).toLocaleString() + : (visitorStats.eventCount ?? 0).toLocaleString()} + + + {t("event.label")} + + + + / + + + + {visitorStats.totalVisitors.toLocaleString()} + + + {t("visitors")} + + + + ) : ( + + + {visitorStats.totalVisitors.toLocaleString()} + + + {t("visitors")} + + + )} + + + + ● {activeVisitors.toLocaleString()} {t("active")} + + + {visitorStats.totalPageviews.toLocaleString()} {t("pageviews")} + + + {visitorStats.totalVisits.toLocaleString()} {t("visits")} + + + {visitorStats.bounceRate}% {t("bounceRate")} + + + {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")} + + + + {viewMode === "events" ? ( + + ) : viewMode === "topPages" ? ( + + ) : viewMode === "topReferrers" ? ( + + ) : chartType === "sparkline" ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx new file mode 100644 index 000000000..0962d3681 --- /dev/null +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -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 ( + + + {t("events.selectPrompt")} + + + ); + } + + // 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 = { 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 ( + + {chartSeries.length > 1 && ( + + {chartSeries.map((serie) => ( + + + + {serie.name} + + + ))} + + )} + + {chartType === "sparkline" ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx new file mode 100644 index 000000000..1d9a87988 --- /dev/null +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -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 ; +} + +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 ( + + ); +} + +function UmamiTopList({ + items, + heading, + emptyLabel, +}: { + items: UmamiMetricItem[]; + heading: string; + emptyLabel: string; +}) { + return ( + + + {heading} + + + + {items.map((item, i) => ( + + + {i + 1}. + + + {item.x || emptyLabel} + + + {item.y.toLocaleString()} + + + ))} + + + + ); +} diff --git a/packages/widgets/src/umami/umami-utils.ts b/packages/widgets/src/umami/umami-utils.ts new file mode 100644 index 000000000..c88f28378 --- /dev/null +++ b/packages/widgets/src/umami/umami-utils.ts @@ -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>, +): string { + if (timeFrameValues.includes(timeFrame)) { + return t(`option.timeFrame.option.${timeFrame}`); + } + return timeFrame; +}