feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,199 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Request, Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { UmamiIntegration } from "../umami-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const PAGE_SIZE = 5_000;
|
||||
const WEBSITE_ID = "site-1";
|
||||
|
||||
interface RequestedUrl {
|
||||
path: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
const requestedUrls: RequestedUrl[] = [];
|
||||
|
||||
const setupMockFetch = (handler: (requested: RequestedUrl) => unknown) => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
|
||||
const parsed = new URL(urlString);
|
||||
const requested = { path: parsed.pathname, params: parsed.searchParams };
|
||||
requestedUrls.push(requested);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(handler(requested) ?? []), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const createIntegration = () =>
|
||||
new UmamiIntegration({
|
||||
id: "test-umami",
|
||||
name: "Test Umami",
|
||||
url: "https://umami.example.com/api",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
|
||||
});
|
||||
|
||||
const rawEvent = (index: number, eventName: string, hour: number) => ({
|
||||
id: `event-${index}`,
|
||||
websiteId: WEBSITE_ID,
|
||||
sessionId: `session-${index}`,
|
||||
eventType: 2,
|
||||
eventName,
|
||||
createdAt: `2026-08-09T${String(hour).padStart(2, "0")}:30:00.000Z`,
|
||||
});
|
||||
|
||||
describe("UmamiIntegration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestedUrls.length = 0;
|
||||
});
|
||||
|
||||
describe("getEventNamesAsync", () => {
|
||||
test("reads names from the aggregated metrics endpoint", async () => {
|
||||
setupMockFetch(({ path }) => {
|
||||
if (path.endsWith("/metrics")) {
|
||||
return [
|
||||
{ x: "signup", y: 12 },
|
||||
{ x: "purchase", y: 3 },
|
||||
{ x: "signup", y: 1 },
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected request to ${path}`);
|
||||
});
|
||||
|
||||
expect(await createIntegration().getEventNamesAsync(WEBSITE_ID)).toEqual(["purchase", "signup"]);
|
||||
expect(requestedUrls).toHaveLength(1);
|
||||
expect(requestedUrls[0]?.path).toContain("/metrics");
|
||||
expect(requestedUrls[0]?.params.get("type")).toBe("event");
|
||||
expect(requestedUrls[0]?.params.get("limit")).toBe("5000");
|
||||
expect(requestedUrls.some((requested) => requested.path.endsWith("/events"))).toBe(false);
|
||||
});
|
||||
|
||||
test("drops empty names and returns them sorted", async () => {
|
||||
setupMockFetch(() => [
|
||||
{ x: "beta", y: 1 },
|
||||
{ x: null, y: 5 },
|
||||
{ x: "alpha", y: 2 },
|
||||
]);
|
||||
|
||||
expect(await createIntegration().getEventNamesAsync(WEBSITE_ID)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMultiEventTimeSeriesAsync", () => {
|
||||
test("pages through raw event records and aggregates across pages", async () => {
|
||||
const firstPage = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
|
||||
const secondPage = Array.from({ length: 5 }, (_, index) => rawEvent(PAGE_SIZE + index, "signup", 11));
|
||||
|
||||
setupMockFetch(({ path, params }) => {
|
||||
if (!path.endsWith("/events")) return [];
|
||||
return { data: params.get("page") === "1" ? firstPage : secondPage };
|
||||
});
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
|
||||
|
||||
expect(requestedUrls.map((requested) => requested.params.get("page"))).toEqual(["1", "2"]);
|
||||
expect(requestedUrls[0]?.params.get("pageSize")).toBe(String(PAGE_SIZE));
|
||||
expect(requestedUrls[0]?.params.get("event")).toBe("signup");
|
||||
expect(requestedUrls[0]?.params.get("eventName")).toBe("signup");
|
||||
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(PAGE_SIZE + 5);
|
||||
expect(result[0]?.dataPoints).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("stops after one page when the instance returns aggregated buckets", async () => {
|
||||
setupMockFetch(() => [
|
||||
{ x: "2026-08-09 10:00:00", y: 4 },
|
||||
{ x: "2026-08-09 11:00:00", y: 7 },
|
||||
]);
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
|
||||
|
||||
expect(requestedUrls).toHaveLength(1);
|
||||
expect(result[0]?.dataPoints).toEqual([
|
||||
{ x: "2026-08-09 10:00:00", y: 4 },
|
||||
{ x: "2026-08-09 11:00:00", y: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("stops instead of double counting when the instance ignores the page parameter", async () => {
|
||||
const page = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
|
||||
setupMockFetch(() => ({ data: page }));
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
|
||||
|
||||
expect(requestedUrls).toHaveLength(2);
|
||||
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(PAGE_SIZE);
|
||||
});
|
||||
|
||||
test("discards partial data when a later page fails", async () => {
|
||||
const firstPage = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
|
||||
const parsed = new URL(urlString);
|
||||
const isSecondPage = parsed.searchParams.get("page") === "2";
|
||||
return Promise.resolve(
|
||||
new Response(isSecondPage ? "upstream failure" : JSON.stringify({ data: firstPage }), {
|
||||
status: isSecondPage ? 500 : 200,
|
||||
headers: { "content-type": isSecondPage ? "text/plain" : "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
});
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]?.dataPoints).toEqual([]);
|
||||
});
|
||||
|
||||
test("probes beyond the record ceiling without counting the sentinel page", async () => {
|
||||
setupMockFetch(({ params }) => {
|
||||
const page = Number(params.get("page"));
|
||||
return {
|
||||
data: Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(page * PAGE_SIZE + index, "signup", 10)),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
|
||||
|
||||
expect(requestedUrls).toHaveLength(21);
|
||||
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(20 * PAGE_SIZE);
|
||||
});
|
||||
|
||||
test("requests event series one at a time", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
mockFetch.mockImplementation(async () => {
|
||||
inFlight++;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
inFlight--;
|
||||
return new Response(JSON.stringify([{ x: "2026-08-09 10:00:00", y: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
});
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [
|
||||
"signup",
|
||||
"purchase",
|
||||
"refund",
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(maxInFlight).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -8,6 +9,7 @@ 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 { IntegrationHttpAuthentication } from "../http-auth";
|
||||
import type {
|
||||
UmamiEventSeries,
|
||||
UmamiMetricItem,
|
||||
@@ -29,10 +31,41 @@ import {
|
||||
|
||||
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
|
||||
const UMAMI_CUSTOM_EVENT_TYPE = 2;
|
||||
const UMAMI_EVENT_NAME_LIMIT = 5_000;
|
||||
const UMAMI_EVENT_PAGE_SIZE = 5_000;
|
||||
const UMAMI_EVENT_PAGE_MAX = 20;
|
||||
|
||||
const logger = createLogger({ module: "umami-integration" });
|
||||
|
||||
const pad = (number: number) => String(number).padStart(2, "0");
|
||||
|
||||
const extractDataArray = (json: unknown): unknown[] => {
|
||||
if (Array.isArray(json)) return json;
|
||||
if (isRecord(json) && Array.isArray(json.data)) {
|
||||
return json.data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export class UmamiIntegration extends Integration {
|
||||
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
const apiKey = this.getSecretValue("apiKey");
|
||||
|
||||
return {
|
||||
headers: { "x-umami-api-key": apiKey },
|
||||
redactValues: [apiKey],
|
||||
};
|
||||
}
|
||||
|
||||
const token = await this.getJwtTokenAsync();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redactValues: [token],
|
||||
};
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
const url = this.url("/websites");
|
||||
@@ -170,32 +203,21 @@ export class UmamiIntegration extends Integration {
|
||||
|
||||
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
const now = Date.now();
|
||||
const endAt = 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 metrics = await this.getWebsiteMetricsAsync(
|
||||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
"event",
|
||||
authHeaders,
|
||||
UMAMI_EVENT_NAME_LIMIT,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
for (const item of rawArray) {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.eventName === "string" && record.eventName) {
|
||||
names.add(record.eventName);
|
||||
}
|
||||
for (const metric of metrics) {
|
||||
if (metric.x) names.add(metric.x);
|
||||
}
|
||||
return [...names].sort();
|
||||
return [...names].toSorted();
|
||||
}
|
||||
|
||||
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
|
||||
@@ -243,19 +265,18 @@ export class UmamiIntegration extends Integration {
|
||||
): Promise<UmamiEventSeries[]> {
|
||||
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 ?? [] };
|
||||
}),
|
||||
);
|
||||
const results: UmamiEventSeries[] = [];
|
||||
for (const eventName of eventNames) {
|
||||
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
|
||||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
eventName,
|
||||
authHeaders,
|
||||
);
|
||||
results.push({ eventName, dataPoints: dataPoints ?? [] });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -306,79 +327,82 @@ export class UmamiIntegration extends Integration {
|
||||
eventName: string,
|
||||
authHeaders: Record<string, string>,
|
||||
): Promise<UmamiPageviewDataPoint[] | undefined> {
|
||||
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,
|
||||
});
|
||||
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`;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
|
||||
return undefined;
|
||||
}
|
||||
const buckets = new Map<string, number>();
|
||||
let previousFirstId: string | undefined;
|
||||
let truncated = false;
|
||||
|
||||
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
|
||||
: [];
|
||||
for (let page = 1; ; page++) {
|
||||
const url = this.url(`/websites/${websiteId}/events`, {
|
||||
startAt: startAt.toString(),
|
||||
endAt: endAt.toString(),
|
||||
unit,
|
||||
timezone: "UTC",
|
||||
event: eventName,
|
||||
eventName,
|
||||
page: page.toString(),
|
||||
pageSize: UMAMI_EVENT_PAGE_SIZE.toString(),
|
||||
});
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName, page });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rawArray.length === 0) return undefined;
|
||||
const rawArray = extractDataArray(await response.json());
|
||||
if (rawArray.length === 0) break;
|
||||
if (page > UMAMI_EVENT_PAGE_MAX) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const firstItem = rawArray[0] as Record<string, unknown>;
|
||||
const firstItem = rawArray[0] as Record<string, unknown>;
|
||||
if ("x" in firstItem && "y" in firstItem) {
|
||||
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
|
||||
return points.length === 0 ? undefined : points;
|
||||
}
|
||||
if (!("createdAt" in firstItem)) return undefined;
|
||||
|
||||
// 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.
|
||||
const firstId =
|
||||
typeof firstItem.id === "string"
|
||||
? firstItem.id
|
||||
: typeof firstItem.createdAt === "string"
|
||||
? `${String(firstItem.sessionId ?? "")}@${firstItem.createdAt}`
|
||||
: undefined;
|
||||
if (page > 1 && firstId !== undefined && firstId === previousFirstId) {
|
||||
logger.warn("Umami ignored event paging, stopping after the first page", { websiteId, eventName });
|
||||
break;
|
||||
}
|
||||
previousFirstId = firstId;
|
||||
|
||||
// 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<string, number>();
|
||||
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 }))
|
||||
);
|
||||
|
||||
if (rawArray.length < UMAMI_EVENT_PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (truncated) {
|
||||
logger.warn("Event time series hit the page ceiling, counts are a lower bound", {
|
||||
websiteId,
|
||||
eventName,
|
||||
maxRecords: UMAMI_EVENT_PAGE_SIZE * UMAMI_EVENT_PAGE_MAX,
|
||||
});
|
||||
}
|
||||
if (buckets.size === 0) return undefined;
|
||||
return Array.from(buckets.entries())
|
||||
.toSorted(([itemA], [itemB]) => itemA.localeCompare(itemB))
|
||||
.map(([x, y]) => ({ x, y }));
|
||||
}
|
||||
|
||||
private async getWebsiteStatsAsync(
|
||||
@@ -439,6 +463,7 @@ export class UmamiIntegration extends Integration {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
redirect: "error",
|
||||
body: JSON.stringify({
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
|
||||
Reference in new issue
Block a user