Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c42ff9962 | ||
|
|
95f542ecb8 |
No files matched your search
@@ -84,11 +84,3 @@ apps/docs/.docusaurus
|
||||
apps/docs/build
|
||||
.pnpm-store
|
||||
benchmark-results
|
||||
|
||||
# local performance investigation (never ship benchmark data or restored backups)
|
||||
.widget-performance/
|
||||
.bench/
|
||||
homarr-backup*.zip
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
/scripts/benchmarks/
|
||||
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
preloadEntriesOnStart: false,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
@@ -39,21 +39,12 @@ interface ItemProps {
|
||||
}
|
||||
|
||||
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
|
||||
if (item.type === "section") return <ContainerSectionContentItem item={item} />;
|
||||
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
return (
|
||||
<FixedGridItem item={item}>
|
||||
{item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
<BoardItemContent item={item} />
|
||||
</FixedGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerSectionContentItem = ({ item }: { item: ContainerSectionItem }) => {
|
||||
const { minWidth, minHeight } = useMinSize(item);
|
||||
return (
|
||||
<FixedGridItem item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
<BoardContainerSection section={item} />
|
||||
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
|
||||
</FixedGridItem>
|
||||
);
|
||||
};
|
||||
@@ -61,18 +52,14 @@ const ContainerSectionContentItem = ({ item }: { item: ContainerSectionItem }) =
|
||||
/**
|
||||
* Calculates a container's minimum width or height from its direct content.
|
||||
*/
|
||||
export const useMinSize = (item: ContainerSectionItem) => {
|
||||
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
const { items, innerSections } = useSectionItems(item.id);
|
||||
return {
|
||||
minWidth: Math.max(
|
||||
1,
|
||||
...items.map((child) => child.xOffset + child.width),
|
||||
...innerSections.map((child) => child.xOffset + child.width),
|
||||
),
|
||||
minHeight: Math.max(
|
||||
1,
|
||||
...items.map((child) => child.yOffset + child.height),
|
||||
...innerSections.map((child) => child.yOffset + child.height),
|
||||
),
|
||||
};
|
||||
if (item.type === "item") return undefined;
|
||||
|
||||
const size = direction === "x" ? "width" : "height";
|
||||
return Math.max(
|
||||
1,
|
||||
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
|
||||
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
|
||||
);
|
||||
};
|
||||
@@ -11,14 +11,11 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
|
||||
|
||||
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const headers = {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
};
|
||||
const [statsResponse, statusResponse, filteringStatusResponse] = await Promise.all([
|
||||
fetchWithTrustedCertificatesAsync(this.url("/control/stats"), { headers }),
|
||||
fetchWithTrustedCertificatesAsync(this.url("/control/status"), { headers }),
|
||||
fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), { headers }),
|
||||
]);
|
||||
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(
|
||||
@@ -26,12 +23,24 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
);
|
||||
}
|
||||
|
||||
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!statusResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!filteringStatusResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`,
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const integration = new AdGuardHomeIntegration({
|
||||
id: "adguard-1",
|
||||
name: "AdGuard Home",
|
||||
url: "https://adguard.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: "admin" },
|
||||
{ kind: "password", value: "secret" },
|
||||
],
|
||||
});
|
||||
|
||||
describe("AdGuardHomeIntegration", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test("starts all summary requests before awaiting any response", async () => {
|
||||
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const path = new URL(url.toString()).pathname;
|
||||
const gate = Promise.withResolvers<Response>();
|
||||
requestGates.set(path, gate);
|
||||
return gate.promise;
|
||||
});
|
||||
|
||||
const resultPromise = integration.getSummaryAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect([...requestGates.keys()]).toEqual(["/control/stats", "/control/status", "/control/filtering/status"]);
|
||||
});
|
||||
|
||||
requestGates.get("/control/stats")?.resolve(jsonResponse(stats));
|
||||
requestGates.get("/control/status")?.resolve(jsonResponse(status));
|
||||
requestGates.get("/control/filtering/status")?.resolve(jsonResponse(filteringStatus));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
status: "enabled",
|
||||
adsBlockedToday: 20,
|
||||
dnsQueriesToday: 100,
|
||||
domainsBeingBlocked: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
|
||||
|
||||
const stats = {
|
||||
time_units: "hours",
|
||||
top_queried_domains: [],
|
||||
top_clients: [],
|
||||
top_blocked_domains: [],
|
||||
dns_queries: [40, 60],
|
||||
blocked_filtering: [5, 15],
|
||||
replaced_safebrowsing: [],
|
||||
replaced_parental: [],
|
||||
num_dns_queries: 100,
|
||||
num_blocked_filtering: 20,
|
||||
num_replaced_safebrowsing: 0,
|
||||
num_replaced_safesearch: 0,
|
||||
num_replaced_parental: 0,
|
||||
avg_processing_time: 0.01,
|
||||
};
|
||||
|
||||
const status = {
|
||||
version: "1.0.0",
|
||||
language: "en",
|
||||
dns_addresses: ["0.0.0.0"],
|
||||
dns_port: 53,
|
||||
http_port: 3000,
|
||||
protection_enabled: true,
|
||||
dhcp_available: false,
|
||||
running: true,
|
||||
};
|
||||
|
||||
const filteringStatus = {
|
||||
filters: [
|
||||
{ url: "https://example.com/filter.txt", name: "Enabled", id: 1, rules_count: 12, enabled: true },
|
||||
{ url: "https://example.com/disabled.txt", name: "Disabled", id: 2, rules_count: 50, enabled: false },
|
||||
],
|
||||
};
|
||||
@@ -26,16 +26,13 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
const [info, cpuLoad, memoryLoad, storageLoad, networkLoad, gpuLoad] = await Promise.all([
|
||||
this.getInfoAsync(),
|
||||
this.getCurrentCpuLoadAsync(),
|
||||
this.getCurrentMemoryLoadAsync(),
|
||||
this.getCurrentStorageLoadAsync(),
|
||||
this.getCurrentNetworkLoadAsync(),
|
||||
this.getCurrentGpuLoadAsync(),
|
||||
]);
|
||||
const info = await this.getInfoAsync();
|
||||
const cpuLoad = await this.getCurrentCpuLoadAsync();
|
||||
const memoryLoad = await this.getCurrentMemoryLoadAsync();
|
||||
const storageLoad = await this.getCurrentStorageLoadAsync();
|
||||
const networkLoad = await this.getCurrentNetworkLoadAsync();
|
||||
const gpuLoad = await this.getCurrentGpuLoadAsync();
|
||||
|
||||
// Read history after the CPU request has persisted its newest sample.
|
||||
const channel = this.getChannel();
|
||||
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createChannelEventHistoryOld } from "../../../../redis/src/lib/channel";
|
||||
import { DashDotIntegration } from "../dashdot-integration";
|
||||
|
||||
vi.mock("@homarr/redis", () => ({}));
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
|
||||
vi.mock("../../../../redis/src/lib/channel", () => ({ createChannelEventHistoryOld: vi.fn() }));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const mockCreateHistory = vi.mocked(createChannelEventHistoryOld);
|
||||
|
||||
const channel = {
|
||||
pushAsync: vi.fn(),
|
||||
getSliceUntilTimeAsync: vi.fn(),
|
||||
};
|
||||
|
||||
const integration = new DashDotIntegration({
|
||||
id: "dashdot-1",
|
||||
name: "DashDot",
|
||||
url: "https://dashdot.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [],
|
||||
});
|
||||
|
||||
describe("DashDotIntegration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateHistory.mockReturnValue(channel as never);
|
||||
});
|
||||
|
||||
test("starts independent requests together and reads history after the CPU sample is persisted", async () => {
|
||||
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
|
||||
const persistenceGate = Promise.withResolvers<void>();
|
||||
const order: string[] = [];
|
||||
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const path = new URL(url.toString()).pathname;
|
||||
const gate = Promise.withResolvers<Response>();
|
||||
requestGates.set(path, gate);
|
||||
return gate.promise;
|
||||
});
|
||||
channel.pushAsync.mockImplementation(async () => {
|
||||
order.push("push-start");
|
||||
await persistenceGate.promise;
|
||||
order.push("push-end");
|
||||
});
|
||||
channel.getSliceUntilTimeAsync.mockImplementation(async () => {
|
||||
order.push("history");
|
||||
return [cpuLoad];
|
||||
});
|
||||
|
||||
const resultPromise = integration.getSystemInfoAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect([...requestGates.keys()]).toEqual([
|
||||
"/info",
|
||||
"/load/cpu",
|
||||
"/load/ram",
|
||||
"/load/storage",
|
||||
"/load/network",
|
||||
"/load/gpu",
|
||||
]);
|
||||
});
|
||||
|
||||
requestGates.get("/info")?.resolve(jsonResponse(serverInfo));
|
||||
requestGates.get("/load/cpu")?.resolve(jsonResponse(cpuLoad));
|
||||
requestGates.get("/load/ram")?.resolve(jsonResponse({ load: 4_000 }));
|
||||
requestGates.get("/load/storage")?.resolve(jsonResponse([250]));
|
||||
requestGates.get("/load/network")?.resolve(jsonResponse({ up: 20, down: 10 }));
|
||||
requestGates.get("/load/gpu")?.resolve(jsonResponse({ layout: [{ load: 30, memory: 40 }] }));
|
||||
|
||||
await vi.waitFor(() => expect(channel.pushAsync).toHaveBeenCalledOnce());
|
||||
expect(channel.getSliceUntilTimeAsync).not.toHaveBeenCalled();
|
||||
|
||||
persistenceGate.resolve();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(order).toEqual(["push-start", "push-end", "history"]);
|
||||
expect(result).toMatchObject({
|
||||
cpuUtilization: 15,
|
||||
memUsedInBytes: 4_000,
|
||||
memAvailableInBytes: 12_000,
|
||||
network: { up: 20, down: 10 },
|
||||
fileSystem: [{ used: "250", available: "750", percentage: 25 }],
|
||||
gpu: [{ memoryUtilization: 40, processorUtilization: 30 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
|
||||
|
||||
const cpuLoad = [
|
||||
{ load: 10, temp: 40 },
|
||||
{ load: 20, temp: 50 },
|
||||
];
|
||||
|
||||
const serverInfo = {
|
||||
os: { distro: "Linux", kernel: "6.0", release: "1", uptime: 60 },
|
||||
cpu: { brand: "Intel", model: "Test CPU" },
|
||||
ram: { size: 16_000 },
|
||||
storage: [{ size: 1_000, disks: [{ device: "sda", brand: "Test", type: "SSD" }] }],
|
||||
gpu: { layout: [{ brand: "Test GPU" }] },
|
||||
};
|
||||
@@ -23,11 +23,9 @@ export class NzbGetIntegration extends Integration implements IDownloadClientInt
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "usenet";
|
||||
const [queue, history, nzbGetStatus] = await Promise.all([
|
||||
this.nzbGetApiCallAsync("listgroups"),
|
||||
this.nzbGetApiCallAsync("history"),
|
||||
this.nzbGetApiCallAsync("status"),
|
||||
]);
|
||||
const queue = await this.nzbGetApiCallAsync("listgroups");
|
||||
const history = await this.nzbGetApiCallAsync("history");
|
||||
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
|
||||
const status: DownloadClientStatus = {
|
||||
paused: nzbGetStatus.DownloadPaused,
|
||||
rates: { down: nzbGetStatus.DownloadRate },
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../../base/types";
|
||||
import { NzbGetIntegration } from "../nzbget-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const secrets: IntegrationSecret[] = [
|
||||
{ kind: "username", value: "test-user" },
|
||||
{ kind: "password", value: "test-password" },
|
||||
];
|
||||
|
||||
const createIntegration = () =>
|
||||
new NzbGetIntegration({
|
||||
id: "test-nzbget",
|
||||
name: "Test NZBGet",
|
||||
url: "https://nzbget.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
describe("NzbGetIntegration.getClientJobsAndStatusAsync", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
test("starts queue, history, and status requests together", async () => {
|
||||
const releaseRequests = Promise.withResolvers<void>();
|
||||
const startedMethods: string[] = [];
|
||||
const results = {
|
||||
listgroups: [],
|
||||
history: [],
|
||||
status: { DownloadPaused: false, DownloadRate: 0 },
|
||||
} as const;
|
||||
|
||||
mockFetch.mockImplementation(async (_url, init) => {
|
||||
const method = (JSON.parse(String(init?.body)) as { method: keyof typeof results }).method;
|
||||
startedMethods.push(method);
|
||||
await releaseRequests.promise;
|
||||
return new Response(JSON.stringify({ result: results[method] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(startedMethods).toEqual(expect.arrayContaining(["listgroups", "history", "status"]));
|
||||
expect(startedMethods).toHaveLength(3);
|
||||
});
|
||||
} finally {
|
||||
releaseRequests.resolve();
|
||||
}
|
||||
|
||||
await expect(resultPromise).resolves.toStrictEqual({
|
||||
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,16 +25,12 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "usenet";
|
||||
const [queueResult, historyResult] = await Promise.all([
|
||||
this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) =>
|
||||
queueSchema.parseAsync(result),
|
||||
),
|
||||
this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }).then((result) =>
|
||||
historySchema.parseAsync(result),
|
||||
),
|
||||
]);
|
||||
const { queue } = queueResult;
|
||||
const { history } = historyResult;
|
||||
const { queue } = await queueSchema.parseAsync(
|
||||
await this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }),
|
||||
);
|
||||
const { history } = await historySchema.parseAsync(
|
||||
await this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }),
|
||||
);
|
||||
const status: DownloadClientStatus = {
|
||||
paused: queue.paused,
|
||||
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../../base/types";
|
||||
import { SabnzbdIntegration } from "../sabnzbd-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }];
|
||||
|
||||
const createIntegration = () =>
|
||||
new SabnzbdIntegration({
|
||||
id: "test-sabnzbd",
|
||||
name: "Test SABnzbd",
|
||||
url: "https://sabnzbd.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
test("starts queue and history requests together", async () => {
|
||||
const releaseRequests = Promise.withResolvers<void>();
|
||||
const startedModes: string[] = [];
|
||||
const results = {
|
||||
queue: { queue: { paused: false, kbpersec: "0", slots: [] } },
|
||||
history: { history: { slots: [] } },
|
||||
} as const;
|
||||
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const mode = new URL(String(url)).searchParams.get("mode") as keyof typeof results;
|
||||
startedModes.push(mode);
|
||||
await releaseRequests.promise;
|
||||
return new Response(JSON.stringify(results[mode]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(startedModes).toEqual(expect.arrayContaining(["queue", "history"]));
|
||||
expect(startedModes).toHaveLength(2);
|
||||
});
|
||||
} finally {
|
||||
releaseRequests.resolve();
|
||||
}
|
||||
|
||||
await expect(resultPromise).resolves.toStrictEqual({
|
||||
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export interface ImmichAsset {
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "immich-integration" });
|
||||
const carouselPhotoCount = 50;
|
||||
const randomPhotoCount = 50;
|
||||
|
||||
export class ImmichIntegration extends Integration {
|
||||
constructor(integration: IntegrationInput) {
|
||||
@@ -74,7 +74,7 @@ export class ImmichIntegration extends Integration {
|
||||
const assets = await searchRandom(
|
||||
{
|
||||
randomSearchDto: {
|
||||
size: carouselPhotoCount,
|
||||
size: randomPhotoCount,
|
||||
type: AssetTypeEnum.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
},
|
||||
@@ -125,15 +125,13 @@ export class ImmichIntegration extends Integration {
|
||||
let nextPage: string | null = null;
|
||||
|
||||
do {
|
||||
const metadataSearchDto: MetadataSearchDto = {
|
||||
albumIds: [albumId],
|
||||
type: AssetTypeEnum.Image,
|
||||
};
|
||||
if (nextPage !== null) metadataSearchDto.page = Number(nextPage);
|
||||
|
||||
const result = await searchAssets({ metadataSearchDto }, requestOptions);
|
||||
assets.push(...result.assets.items);
|
||||
nextPage = result.assets.nextPage;
|
||||
const metadataSearchDto: MetadataSearchDto = { albumIds: [albumId] };
|
||||
if (nextPage !== null) {
|
||||
metadataSearchDto.page = Number(nextPage);
|
||||
}
|
||||
const searchResult = await searchAssets({ metadataSearchDto }, requestOptions);
|
||||
assets.push(...searchResult.assets.items);
|
||||
nextPage = searchResult.assets.nextPage;
|
||||
} while (nextPage !== null);
|
||||
|
||||
return assets;
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { ImmichIntegration } from "../immich-integration";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAlbumInfo: vi.fn(),
|
||||
searchAssets: vi.fn(),
|
||||
createImageAsync: vi.fn<(url: string) => Promise<string>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@immich/sdk", () => ({
|
||||
AssetMediaSize: { Preview: "preview" },
|
||||
AssetTypeEnum: { Image: "IMAGE" },
|
||||
AssetVisibility: { Timeline: "timeline" },
|
||||
getAlbumInfo: mocks.getAlbumInfo,
|
||||
getAllAlbums: vi.fn(),
|
||||
getMyUser: vi.fn(),
|
||||
getServerStatistics: vi.fn(),
|
||||
init: vi.fn(),
|
||||
searchAssets: mocks.searchAssets,
|
||||
searchRandom: vi.fn(),
|
||||
searchUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/image-proxy", () => ({
|
||||
ImageProxy: class {
|
||||
createImageAsync = mocks.createImageAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "immich-token" }];
|
||||
const integration = new ImmichIntegration({
|
||||
id: "test-immich",
|
||||
name: "Test Immich",
|
||||
url: "https://immich.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
const createAsset = (index: number) => ({
|
||||
id: `asset-${index}`,
|
||||
type: "IMAGE",
|
||||
thumbhash: null,
|
||||
fileCreatedAt: "2026-01-01T00:00:00.000Z",
|
||||
fileModifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("ImmichIntegration.getAlbumAsync", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getAlbumInfo.mockReset();
|
||||
mocks.searchAssets.mockReset();
|
||||
mocks.createImageAsync.mockClear();
|
||||
});
|
||||
|
||||
test("keeps every image across paginated album results", async () => {
|
||||
mocks.getAlbumInfo.mockResolvedValue({ id: "album-id", albumName: "Large album" });
|
||||
mocks.searchAssets
|
||||
.mockResolvedValueOnce({
|
||||
assets: {
|
||||
items: Array.from({ length: 6 }, (_, index) => createAsset(index)),
|
||||
nextPage: "2",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assets: {
|
||||
items: Array.from({ length: 6 }, (_, index) => createAsset(index + 6)),
|
||||
nextPage: null,
|
||||
},
|
||||
});
|
||||
mocks.createImageAsync.mockImplementation(async (url) => `proxied:${url}`);
|
||||
|
||||
const album = await integration.getAlbumAsync("album-id");
|
||||
|
||||
expect(mocks.searchAssets).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.searchAssets.mock.calls.map(([input]) => input)).toStrictEqual([
|
||||
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE" } },
|
||||
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE", page: 2 } },
|
||||
]);
|
||||
expect(album.assets.map(({ id }) => id)).toStrictEqual(Array.from({ length: 12 }, (_, index) => `asset-${index}`));
|
||||
expect(mocks.createImageAsync).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
|
||||
@@ -15,7 +8,7 @@ const ws = vi.hoisted(() => {
|
||||
const constructedUrls: string[] = [];
|
||||
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
|
||||
const failures: { path: string; code?: string }[] = [];
|
||||
let responder: (method: string, params: unknown[]) => unknown | Promise<unknown> = () => undefined;
|
||||
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
|
||||
|
||||
type Listener = (...args: unknown[]) => void;
|
||||
|
||||
@@ -65,15 +58,12 @@ const ws = vi.hoisted(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(responder(payload.method as string, (payload.params as unknown[] | undefined) ?? [])).then(
|
||||
(result) => {
|
||||
const envelope =
|
||||
payload.jsonrpc === "2.0"
|
||||
? { jsonrpc: "2.0", id: payload.id, result }
|
||||
: { msg: "result", id: payload.id, result };
|
||||
this.respond(JSON.stringify(envelope));
|
||||
},
|
||||
);
|
||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
||||
const envelope =
|
||||
payload.jsonrpc === "2.0"
|
||||
? { jsonrpc: "2.0", id: payload.id, result }
|
||||
: { msg: "result", id: payload.id, result };
|
||||
this.respond(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -95,7 +85,7 @@ const ws = vi.hoisted(() => {
|
||||
sentPayloads,
|
||||
constructedUrls,
|
||||
failures,
|
||||
setResponder: (fn: (method: string, params: unknown[]) => unknown | Promise<unknown>) => {
|
||||
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
|
||||
responder = fn;
|
||||
},
|
||||
};
|
||||
@@ -220,34 +210,6 @@ describe("TrueNasIntegration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("starts independent system requests together while preserving the network interface dependency", async () => {
|
||||
const gates = new Map<string, ReturnType<typeof Promise.withResolvers<unknown>>>();
|
||||
ws.setResponder((method) => {
|
||||
if (method.startsWith("auth.")) return true;
|
||||
const gate = Promise.withResolvers<unknown>();
|
||||
gates.set(method, gate);
|
||||
return gate.promise;
|
||||
});
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const resultPromise = integration.getSystemInfoAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
|
||||
});
|
||||
expect(gates.has("reporting.netdata_get_data")).toBe(false);
|
||||
|
||||
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
|
||||
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
|
||||
|
||||
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
version: "TrueNAS-SCALE-24.10",
|
||||
network: { up: 20_000, down: 10_000 },
|
||||
});
|
||||
});
|
||||
|
||||
test("authenticates with an API key when one is configured", async () => {
|
||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
||||
|
||||
|
||||
@@ -24,16 +24,15 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
const [systemInformation, reporting, datasets, netdata] = await Promise.all([
|
||||
this.getSystemInformationAsync(),
|
||||
this.getReportingAsync(),
|
||||
this.getPoolsAsync(),
|
||||
this.getReportingNetdataAsync(),
|
||||
]);
|
||||
const systemInformation = await this.getSystemInformationAsync();
|
||||
const reporting = await this.getReportingAsync();
|
||||
|
||||
const cpuData = this.extractLatestReportingData(reporting, "cpu");
|
||||
const cpuTempData = this.extractLatestReportingData(reporting, "cputemp");
|
||||
const memoryData = this.extractLatestReportingData(reporting, "memory");
|
||||
const datasets = await this.getPoolsAsync();
|
||||
|
||||
const netdata = await this.getReportingNetdataAsync();
|
||||
|
||||
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
|
||||
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,22 +29,9 @@ 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 (json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)) {
|
||||
return (json as { data: unknown[] }).data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export class UmamiIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
@@ -183,21 +170,32 @@ export class UmamiIntegration extends Integration {
|
||||
|
||||
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
const endAt = Date.now();
|
||||
const now = Date.now();
|
||||
const startAt = dayjs().subtract(30, "day").valueOf();
|
||||
const metrics = await this.getWebsiteMetricsAsync(
|
||||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
"event",
|
||||
authHeaders,
|
||||
UMAMI_EVENT_NAME_LIMIT,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
for (const metric of metrics) {
|
||||
if (metric.x) names.add(metric.x);
|
||||
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 [];
|
||||
}
|
||||
return [...names].toSorted();
|
||||
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<string>();
|
||||
for (const item of rawArray) {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.eventName === "string" && record.eventName) {
|
||||
names.add(record.eventName);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
|
||||
@@ -245,18 +243,19 @@ export class UmamiIntegration extends Integration {
|
||||
): Promise<UmamiEventSeries[]> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
|
||||
const results: UmamiEventSeries[] = [];
|
||||
for (const eventName of eventNames) {
|
||||
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
|
||||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
eventName,
|
||||
authHeaders,
|
||||
);
|
||||
results.push({ eventName, dataPoints: dataPoints ?? [] });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -307,82 +306,79 @@ export class UmamiIntegration extends Integration {
|
||||
eventName: string,
|
||||
authHeaders: Record<string, string>,
|
||||
): Promise<UmamiPageviewDataPoint[] | undefined> {
|
||||
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 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 buckets = new Map<string, number>();
|
||||
let previousFirstId: string | undefined;
|
||||
let truncated = false;
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 rawArray = extractDataArray(await response.json());
|
||||
if (rawArray.length === 0) break;
|
||||
if (page > UMAMI_EVENT_PAGE_MAX) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (rawArray.length === 0) return undefined;
|
||||
|
||||
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;
|
||||
const firstItem = rawArray[0] as Record<string, unknown>;
|
||||
|
||||
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;
|
||||
// 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<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 (rawArray.length < UMAMI_EVENT_PAGE_SIZE) break;
|
||||
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 (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 }));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getWebsiteStatsAsync(
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { MediaRequestStats } from "@homarr/integrations/types";
|
||||
|
||||
import { mediaRequestStatsRequestHandler } from "./media-request-stats";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
const integration = {
|
||||
id: "media-request-integration",
|
||||
kind: "overseerr",
|
||||
} as Parameters<typeof mediaRequestStatsRequestHandler.handler>[0];
|
||||
|
||||
describe("mediaRequestStatsRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
mediaRequestStatsRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("starts independent upstream requests together", async () => {
|
||||
const requestsStarted = Promise.withResolvers<void>();
|
||||
const expected = {
|
||||
stats: {
|
||||
total: 8,
|
||||
movie: 5,
|
||||
tv: 3,
|
||||
pending: 1,
|
||||
approved: 2,
|
||||
declined: 1,
|
||||
processing: 1,
|
||||
available: 3,
|
||||
},
|
||||
users: [{ id: 1, displayName: "User", avatar: "/avatar.png", requestCount: 4, link: "/users/1" }],
|
||||
} satisfies MediaRequestStats;
|
||||
const getStatsAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return expected.stats;
|
||||
});
|
||||
const getUsersAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return expected.users;
|
||||
});
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getStatsAsync, getUsersAsync });
|
||||
|
||||
const resultPromise = mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getStatsAsync).toHaveBeenCalledOnce();
|
||||
expect(getUsersAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
requestsStarted.resolve();
|
||||
await expect(resultPromise).resolves.toMatchObject({ data: expected });
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,9 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [stats, users] = await Promise.all([
|
||||
integrationInstance.getStatsAsync(),
|
||||
integrationInstance.getUsersAsync(),
|
||||
]);
|
||||
return {
|
||||
stats,
|
||||
users,
|
||||
stats: await integrationInstance.getStatsAsync(),
|
||||
users: await integrationInstance.getUsersAsync(),
|
||||
};
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { mediaTranscodingRequestHandler } from "./media-transcoding";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
const integration = {
|
||||
id: "tdarr-integration",
|
||||
kind: "tdarr",
|
||||
} as Parameters<typeof mediaTranscodingRequestHandler.handler>[0];
|
||||
|
||||
describe("mediaTranscodingRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
mediaTranscodingRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("starts independent upstream requests together", async () => {
|
||||
const requestsStarted = Promise.withResolvers<void>();
|
||||
const getQueueAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return { array: [], totalCount: 0, startIndex: 0, endIndex: 0 };
|
||||
});
|
||||
const getWorkersAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return [];
|
||||
});
|
||||
const getStatisticsAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return {};
|
||||
});
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getQueueAsync, getWorkersAsync, getStatisticsAsync });
|
||||
|
||||
const resultPromise = mediaTranscodingRequestHandler
|
||||
.handler(integration, { pageOffset: 0, pageSize: 10 })
|
||||
.getDataAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getQueueAsync).toHaveBeenCalledOnce();
|
||||
expect(getWorkersAsync).toHaveBeenCalledOnce();
|
||||
expect(getStatisticsAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
requestsStarted.resolve();
|
||||
await resultPromise;
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,10 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [queue, workers, statistics] = await Promise.all([
|
||||
integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||
integrationInstance.getWorkersAsync(),
|
||||
integrationInstance.getStatisticsAsync(),
|
||||
]);
|
||||
return {
|
||||
queue,
|
||||
workers,
|
||||
statistics,
|
||||
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||
workers: await integrationInstance.getWorkersAsync(),
|
||||
statistics: await integrationInstance.getStatisticsAsync(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,35 +19,23 @@ export function OverflowBadge({
|
||||
radius: "sm",
|
||||
...props,
|
||||
};
|
||||
|
||||
const hasOverflow = data.length > overflowCount;
|
||||
const badges = data.slice(0, overflowCount).map((item) => (
|
||||
<Badge key={item} px="xs" {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
));
|
||||
const overflowTrigger = hasOverflow ? (
|
||||
<UnstyledButton display="flex">
|
||||
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
|
||||
+{data.length - overflowCount}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
) : null;
|
||||
|
||||
if (disablePopover || !hasOverflow) {
|
||||
return (
|
||||
<Group gap={groupGap}>
|
||||
{badges}
|
||||
{overflowTrigger}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover width="content" shadow="md">
|
||||
<Popover width="content" shadow="md" disabled={disablePopover}>
|
||||
<Group gap={groupGap}>
|
||||
{badges}
|
||||
<Popover.Target>{overflowTrigger}</Popover.Target>
|
||||
{data.slice(0, overflowCount).map((item) => (
|
||||
<Badge key={item} px="xs" {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
{data.length > overflowCount && (
|
||||
<Popover.Target>
|
||||
<UnstyledButton display="flex">
|
||||
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
|
||||
+{data.length - overflowCount}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
)}
|
||||
</Group>
|
||||
<Popover.Dropdown>
|
||||
<Stack>
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
IconTriangleFilled,
|
||||
IconZoomScan,
|
||||
} from "@tabler/icons-react";
|
||||
import { escapeForRegEx } from "@tiptap/react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -61,8 +63,6 @@ interface FormValidation {
|
||||
errors: FormErrors;
|
||||
}
|
||||
|
||||
const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
property,
|
||||
kind,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { ActionIconProps } from "@mantine/core";
|
||||
import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core";
|
||||
import { IconGripHorizontal } from "@tabler/icons-react";
|
||||
|
||||
@@ -176,6 +177,21 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
id,
|
||||
});
|
||||
|
||||
const Handle = (props: Partial<ActionIconProps>) => {
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
{...props}
|
||||
{...listeners}
|
||||
ref={setActivatorNodeRef}
|
||||
style={{ cursor: "grab" }}
|
||||
>
|
||||
<IconGripHorizontal />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
@@ -206,17 +222,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
rootAttributes={attributes}
|
||||
handle={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
{...listeners}
|
||||
ref={setActivatorNodeRef}
|
||||
style={{ cursor: "grab" }}
|
||||
>
|
||||
<IconGripHorizontal />
|
||||
</ActionIcon>
|
||||
}
|
||||
handle={Handle}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
import type ReactQuillComponent from "react-quill-new";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
@@ -16,11 +21,102 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { storedContentToPlainText } from "./content";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import "./anchor-note.css";
|
||||
|
||||
const AnchorNoteEditor = dynamic(() => import("./editor"), { ssr: false });
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
|
||||
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
|
||||
|
||||
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
|
||||
|
||||
const quillDeltaOperationSchema = z.object({
|
||||
insert: z.unknown().optional(),
|
||||
attributes: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const quillDeltaSchema = z.object({
|
||||
ops: z.array(quillDeltaOperationSchema),
|
||||
});
|
||||
|
||||
type QuillDelta = z.infer<typeof quillDeltaSchema>;
|
||||
|
||||
const DeltaConstructor = Quill.import("delta") as new (
|
||||
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
|
||||
) => DeltaStatic;
|
||||
|
||||
const quillModules = {
|
||||
toolbar: [
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ header: [1, 2, 3, 4, false] }],
|
||||
[{ align: [] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
|
||||
[{ indent: "-1" }, { indent: "+1" }],
|
||||
["blockquote", "code-block"],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
history: {
|
||||
delay: 1000,
|
||||
maxStack: 200,
|
||||
userOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const readOnlyModules = {
|
||||
toolbar: false,
|
||||
};
|
||||
|
||||
const quillFormats = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"header",
|
||||
"align",
|
||||
"list",
|
||||
"indent",
|
||||
"blockquote",
|
||||
"code-block",
|
||||
"link",
|
||||
];
|
||||
|
||||
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
|
||||
|
||||
const toPlainTextDelta = (value: string): DeltaStatic => {
|
||||
const normalized = value.endsWith("\n") ? value : `${value}\n`;
|
||||
return new DeltaConstructor([{ insert: normalized }]);
|
||||
};
|
||||
|
||||
const parseStoredContent = (content?: string | null): DeltaStatic => {
|
||||
if (!content) return emptyDelta();
|
||||
|
||||
try {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
|
||||
if (parsedDelta.success) {
|
||||
return new DeltaConstructor(parsedDelta.data.ops);
|
||||
}
|
||||
} catch {
|
||||
return toPlainTextDelta(content);
|
||||
}
|
||||
|
||||
return toPlainTextDelta(content);
|
||||
};
|
||||
|
||||
const stringifyDelta = (delta: unknown): string => {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(delta);
|
||||
if (parsedDelta.success) {
|
||||
return JSON.stringify(parsedDelta.data);
|
||||
}
|
||||
return JSON.stringify(emptyDelta());
|
||||
};
|
||||
|
||||
export const storedContentToPlainText = (content?: string | null): string => {
|
||||
return parseStoredContent(content)
|
||||
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const canEditPermission = (permission: AnchorNotePermission) => {
|
||||
return permission === "owner" || permission === "editor";
|
||||
@@ -121,7 +217,17 @@ const AnchorNoteWidgetContent = ({
|
||||
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
|
||||
}, [draftContent, draftTitle, note]);
|
||||
|
||||
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
|
||||
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
|
||||
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
|
||||
const handleEditorChange = useCallback<ReactQuillOnChange>(
|
||||
(_html, _delta, source, editor) => {
|
||||
if (!isEditing || source !== "user") return;
|
||||
const next = stringifyDelta(editor.getContents());
|
||||
setDraftContent(next);
|
||||
},
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (!canEdit || !note) return;
|
||||
@@ -276,10 +382,13 @@ const AnchorNoteWidgetContent = ({
|
||||
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<AnchorNoteEditor
|
||||
content={isEditing ? draftContent : note.content}
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
onChange={setDraftContent}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseStoredOperations, storedContentToPlainText, stringifyDelta } from "./content";
|
||||
|
||||
describe("Anchor Note stored content", () => {
|
||||
it("preserves valid Delta operations and extracts their text", () => {
|
||||
const content = JSON.stringify({
|
||||
ops: [{ insert: "Hello ", attributes: { bold: true } }, { insert: { image: "ignored" } }, { insert: "world\n" }],
|
||||
});
|
||||
|
||||
expect(parseStoredOperations(content)).toEqual([
|
||||
{ insert: "Hello ", attributes: { bold: true } },
|
||||
{ insert: { image: "ignored" } },
|
||||
{ insert: "world\n" },
|
||||
]);
|
||||
expect(storedContentToPlainText(content)).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("keeps legacy plain text readable and normalizes an editor newline", () => {
|
||||
expect(parseStoredOperations("legacy note")).toEqual([{ insert: "legacy note\n" }]);
|
||||
expect(storedContentToPlainText("legacy note")).toBe("legacy note");
|
||||
});
|
||||
|
||||
it("falls back to an empty Delta when editor output is invalid", () => {
|
||||
expect(stringifyDelta({ invalid: true })).toBe('{"ops":[{"insert":"\\n"}]}');
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const quillDeltaOperationSchema = z.object({
|
||||
insert: z.unknown().optional(),
|
||||
attributes: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const quillDeltaSchema = z.object({
|
||||
ops: z.array(quillDeltaOperationSchema),
|
||||
});
|
||||
|
||||
export type QuillDelta = z.infer<typeof quillDeltaSchema>;
|
||||
|
||||
const toPlainTextOperations = (value: string): QuillDelta["ops"] => [
|
||||
{ insert: value.endsWith("\n") ? value : `${value}\n` },
|
||||
];
|
||||
|
||||
export const parseStoredOperations = (content?: string | null): QuillDelta["ops"] => {
|
||||
if (!content) return [{ insert: "\n" }];
|
||||
|
||||
try {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
|
||||
if (parsedDelta.success) return parsedDelta.data.ops;
|
||||
} catch {
|
||||
return toPlainTextOperations(content);
|
||||
}
|
||||
|
||||
return toPlainTextOperations(content);
|
||||
};
|
||||
|
||||
export const stringifyDelta = (delta: unknown): string => {
|
||||
const parsedDelta = quillDeltaSchema.safeParse(delta);
|
||||
return JSON.stringify(parsedDelta.success ? parsedDelta.data : { ops: [{ insert: "\n" }] });
|
||||
};
|
||||
|
||||
export const storedContentToPlainText = (content?: string | null): string =>
|
||||
parseStoredOperations(content)
|
||||
.map(({ insert }) => (typeof insert === "string" ? insert : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import ReactQuill, { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
|
||||
import type { QuillDelta } from "./content";
|
||||
import { parseStoredOperations, stringifyDelta } from "./content";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
type ReactQuillOnChange = NonNullable<ComponentProps<typeof ReactQuill>["onChange"]>;
|
||||
|
||||
const DeltaConstructor = Quill.import("delta") as new (
|
||||
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
|
||||
) => DeltaStatic;
|
||||
|
||||
const quillModules = {
|
||||
toolbar: [
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ header: [1, 2, 3, 4, false] }],
|
||||
[{ align: [] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
|
||||
[{ indent: "-1" }, { indent: "+1" }],
|
||||
["blockquote", "code-block"],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
history: {
|
||||
delay: 1000,
|
||||
maxStack: 200,
|
||||
userOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const readOnlyModules = {
|
||||
toolbar: false,
|
||||
};
|
||||
|
||||
const quillFormats = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"header",
|
||||
"align",
|
||||
"list",
|
||||
"indent",
|
||||
"blockquote",
|
||||
"code-block",
|
||||
"link",
|
||||
];
|
||||
|
||||
interface AnchorNoteEditorProps {
|
||||
content?: string | null;
|
||||
readOnly: boolean;
|
||||
placeholder: string;
|
||||
onChange: (content: string) => void;
|
||||
}
|
||||
|
||||
export default function AnchorNoteEditor({ content, readOnly, placeholder, onChange }: AnchorNoteEditorProps) {
|
||||
const value = useMemo(() => new DeltaConstructor(parseStoredOperations(content)), [content]);
|
||||
const handleChange = useCallback<ReactQuillOnChange>(
|
||||
(_html, _delta, source, editor) => {
|
||||
if (readOnly || source !== "user") return;
|
||||
onChange(stringifyDelta(editor.getContents()));
|
||||
},
|
||||
[onChange, readOnly],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
modules={readOnly ? readOnlyModules : quillModules}
|
||||
formats={quillFormats}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -59,9 +59,17 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
return (
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
|
||||
<AppDescriptionTooltip
|
||||
description={app.description}
|
||||
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
@@ -115,7 +123,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</AppDescriptionTooltip>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled &&
|
||||
!settings.forceDisableStatus &&
|
||||
!board.disableStatus &&
|
||||
@@ -140,29 +148,6 @@ interface AppLinkProps {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const AppDescriptionTooltip = ({
|
||||
description,
|
||||
enabled,
|
||||
children,
|
||||
}: PropsWithChildren<{ description?: string | null; enabled: boolean }>) =>
|
||||
enabled ? (
|
||||
<Tooltip.Floating
|
||||
label={description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
{children}
|
||||
</Tooltip.Floating>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
|
||||
enabled ? (
|
||||
<UnstyledButton
|
||||
|
||||
@@ -98,22 +98,6 @@ const getSizeConfig = (width: number): SizeConfig => {
|
||||
};
|
||||
};
|
||||
|
||||
const PercentCell = ({ value, size }: { value: number; size: SizeConfig }) => {
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={value}
|
||||
color={thresholdColor(value)}
|
||||
size={getProgressTrackSize(size.progressSize)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BeszelSystemTableWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
@@ -192,6 +176,20 @@ export default function BeszelSystemTableWidget({
|
||||
});
|
||||
}, [systemsWithSource, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={value}
|
||||
color={thresholdColor(value)}
|
||||
size={getProgressTrackSize(size.progressSize)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
|
||||
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
|
||||
{
|
||||
@@ -272,7 +270,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.cpu} size={size} />,
|
||||
render: (record) => <PercentCell value={record.cpu} />,
|
||||
},
|
||||
visibleMetricKeys.has("showMemory") && {
|
||||
accessor: "memory",
|
||||
@@ -284,7 +282,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.memory} size={size} />,
|
||||
render: (record) => <PercentCell value={record.memory} />,
|
||||
},
|
||||
visibleMetricKeys.has("showDisk") && {
|
||||
accessor: "disk",
|
||||
@@ -316,7 +314,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.gpu} size={size} />,
|
||||
render: (record) => <PercentCell value={record.gpu} />,
|
||||
},
|
||||
visibleMetricKeys.has("showLoadAvg") && {
|
||||
accessor: "loadAvg",
|
||||
|
||||
@@ -31,7 +31,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: s
|
||||
if (timePeriod === "1m") {
|
||||
return { fmt, ordered: records };
|
||||
}
|
||||
return { fmt, ordered: records.toReversed() };
|
||||
return { fmt, ordered: [...records].toReversed() };
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
|
||||
@@ -28,11 +28,11 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
withBorder: factory.switch({ defaultValue: false }),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
||||
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
{handle}
|
||||
<Handle />
|
||||
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
|
||||
@@ -22,30 +22,6 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
const shouldScaleDown = minAxisSize < 350;
|
||||
const isSmall = rootHeight < 256;
|
||||
|
||||
const cell = (
|
||||
<Container
|
||||
h="100%"
|
||||
w="100%"
|
||||
p={0}
|
||||
pt={isSmall ? 0 : 10}
|
||||
pb={isSmall ? 0 : 10}
|
||||
m={0}
|
||||
pos="relative"
|
||||
style={{
|
||||
alignContent: "center",
|
||||
borderRadius: actualItemRadius,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<NotificationIndicator events={events} isSmall={isSmall} />
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (disabled) return cell;
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
position="bottom"
|
||||
@@ -56,8 +32,29 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
transitionProps={{ transition: "pop" }}
|
||||
openDelay={350}
|
||||
closeDelay={400}
|
||||
disabled={disabled}
|
||||
>
|
||||
<HoverCard.Target>{cell}</HoverCard.Target>
|
||||
<HoverCard.Target>
|
||||
<Container
|
||||
h="100%"
|
||||
w="100%"
|
||||
p={0}
|
||||
pt={isSmall ? 0 : 10}
|
||||
pb={isSmall ? 0 : 10}
|
||||
m={0}
|
||||
pos="relative"
|
||||
style={{
|
||||
alignContent: "center",
|
||||
borderRadius: actualItemRadius,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
<NotificationIndicator events={events} isSmall={isSmall} />
|
||||
</Container>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
|
||||
<CalendarEventList events={events} />
|
||||
</HoverCard.Dropdown>
|
||||
|
||||
@@ -45,6 +45,8 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
|
||||
const runningApps = instance.instanceInfo.applications.filter(
|
||||
(a) => parseStatus(a.status ?? "") === "running",
|
||||
@@ -138,7 +140,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
variant="filled"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
keepMounted={false}
|
||||
value={isAdvanced ? advancedOpenSections : openSections}
|
||||
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
|
||||
>
|
||||
@@ -166,26 +167,20 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -50,6 +50,8 @@ export function SingleInstanceLayout({
|
||||
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap={0}>
|
||||
@@ -85,7 +87,6 @@ export function SingleInstanceLayout({
|
||||
variant="contained"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
keepMounted={false}
|
||||
value={isAdvanced ? advancedOpenSections : openSections}
|
||||
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
|
||||
>
|
||||
@@ -113,30 +114,24 @@ export function SingleInstanceLayout({
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const relativeTime = useTimeAgo(updatedAt, 60_000);
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
v{version}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconDeviceGamepad,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showToggleAllButtons: factory.switch({
|
||||
|
||||
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconAd,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
|
||||
@@ -31,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
||||
integrationIds: scope.integrationIds,
|
||||
limitPerIntegration: scope.options.limitPerIntegration,
|
||||
}),
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
|
||||
icon: IconWall,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
||||
icon: IconHeartRateMonitor,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
|
||||
<Stack h="100%" w="100%" gap={0}>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<iframe
|
||||
loading="lazy"
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={safeEmbedUrl}
|
||||
|
||||
@@ -237,7 +237,6 @@ function Carousel({
|
||||
<Image
|
||||
src={asset.publicLink}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
w={64}
|
||||
h={44}
|
||||
radius="sm"
|
||||
|
||||
@@ -150,23 +150,4 @@ describe("widget manifest promise stability", () => {
|
||||
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not poll integration handlers faster than their cache can refresh", async () => {
|
||||
const definitions = await loadAllWidgetDefinitions();
|
||||
const cachedWidgetKinds = [
|
||||
"dnsHoleControls",
|
||||
"dnsHoleSummary",
|
||||
"downloads",
|
||||
"firewall",
|
||||
"healthMonitoring",
|
||||
"mediaServer",
|
||||
"systemDisks",
|
||||
"systemResources",
|
||||
"tracearr",
|
||||
] as const;
|
||||
|
||||
for (const kind of cachedWidgetKinds) {
|
||||
expect(definitions.get(kind)?.refetchInterval).toBe(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -168,7 +168,6 @@ export default function MediaMissingWidget({
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
keepMounted={false}
|
||||
onChange={(value) => {
|
||||
if (value === "missing" || value === "queued") setSelectedTab(value);
|
||||
}}
|
||||
@@ -194,12 +193,12 @@ export default function MediaMissingWidget({
|
||||
|
||||
{showMissing && (
|
||||
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
|
||||
{activeTab === "missing" && renderPanel(missing, t("empty.missing"))}
|
||||
{renderPanel(missing, t("empty.missing"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showQueued && (
|
||||
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
|
||||
{activeTab === "queued" && renderPanel(queued, t("empty.queued"))}
|
||||
{renderPanel(queued, t("empty.queued"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -17,11 +17,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.item[data-defer-rendering] {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 88px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item {
|
||||
transition: none;
|
||||
|
||||
@@ -125,7 +125,6 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.item}
|
||||
data-defer-rendering={isAdvanced ? undefined : true}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
@@ -168,7 +167,6 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
src={item.imageUrls.poster}
|
||||
alt=""
|
||||
radius="xs"
|
||||
loading="lazy"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
|
||||
integrationIds: scope.integrationIds,
|
||||
showOnlyPlaying: scope.options.showOnlyPlaying,
|
||||
}),
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
|
||||
@@ -874,30 +874,27 @@ function TaskListToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveListItemType() {
|
||||
function ListIndentIncrease() {
|
||||
const { editor } = useRichTextEditorContext();
|
||||
const [itemType, setItemType] = useState("listItem");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const updateItemType = () => setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
updateItemType();
|
||||
editor.on("selectionUpdate", updateItemType);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updateItemType);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return { editor, itemType };
|
||||
}
|
||||
|
||||
function ListIndentIncrease() {
|
||||
const { editor, itemType } = useActiveListItemType();
|
||||
const tControls = useScopedI18n("widget.notebook.controls");
|
||||
const handleIncreaseIndent = useCallback(() => {
|
||||
editor?.chain().focus().sinkListItem(itemType).run();
|
||||
}, [editor, itemType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
|
||||
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", handleSelectionUpdate);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handleSelectionUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor.Control
|
||||
title={tControls("increaseIndent")}
|
||||
@@ -910,13 +907,27 @@ function ListIndentIncrease() {
|
||||
}
|
||||
|
||||
function ListIndentDecrease() {
|
||||
const { editor, itemType } = useActiveListItemType();
|
||||
const { editor } = useRichTextEditorContext();
|
||||
const [itemType, setItemType] = useState("listItem");
|
||||
const tControls = useScopedI18n("widget.notebook.controls");
|
||||
|
||||
const handleDecreaseIndent = useCallback(() => {
|
||||
editor?.chain().focus().liftListItem(itemType).run();
|
||||
}, [editor, itemType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
|
||||
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", handleSelectionUpdate);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handleSelectionUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor.Control
|
||||
title={tControls("decreaseIndent")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
||||
import type { ActionIconProps } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
import type { RefinementCtx, ZodType } from "zod/v4";
|
||||
|
||||
@@ -37,7 +38,7 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
|
||||
item: TItem;
|
||||
removeItem: () => void;
|
||||
rootAttributes: DraggableAttributes;
|
||||
handle: React.ReactNode;
|
||||
handle: (props: Partial<Pick<ActionIconProps, "size" | "color" | "variant">>) => React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
uniqueIdentifier: (item: TItem) => TOptionValue;
|
||||
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
*/
|
||||
export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
|
||||
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
|
||||
|
||||
@@ -131,12 +131,11 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
const resultById = new Map(results.flat().map((result) => [result.id, result]));
|
||||
const formattedResults = normalizedRepositories
|
||||
.map((repository) => {
|
||||
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
|
||||
|
||||
const repositoryResult = resultById.get(repository.id);
|
||||
const repositoryResult = results.flat().find(({ id }) => id === repository.id);
|
||||
if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } };
|
||||
if (!repositoryResult.success) return { ...repository, error: repositoryResult.error };
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
|
||||
<Image
|
||||
loading="lazy"
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt=""
|
||||
|
||||
@@ -104,9 +104,8 @@ const SystemDiskCard = ({
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(card);
|
||||
observer.observe(value);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [displayText, healthy]);
|
||||
|
||||
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
||||
const hasTemperature = temperature !== null && temperature !== undefined;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconServer2,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -16,7 +16,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
|
||||
icon: IconGraphFilled,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
|
||||
icon: IconActivityHeartbeat,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: 10,
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showStreams: factory.switch({
|
||||
|
||||
@@ -63,7 +63,7 @@ export function UmamiContent({
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
|
||||
);
|
||||
const multiEventSeries = getUsableWidgetQueryData(multiEventQuery);
|
||||
const multiEventSeries = multiEventQuery.data;
|
||||
|
||||
const multiEventTotal = multiEventSeries
|
||||
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
|
||||
@@ -106,9 +106,10 @@ export function UmamiContent({
|
||||
const selectedView =
|
||||
viewMode === "events" ? (
|
||||
<UmamiEventsContent
|
||||
series={multiEventSeries ?? []}
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
hasSelectedEvents={eventNames.length > 0}
|
||||
eventNames={eventNames}
|
||||
chartType={chartType}
|
||||
showXAxis={showXAxis}
|
||||
/>
|
||||
|
||||
@@ -3,23 +3,27 @@
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UmamiEventSeries } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
|
||||
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
interface UmamiEventsContentProps {
|
||||
series: UmamiEventSeries[];
|
||||
integrationIds: string[];
|
||||
websiteId: string;
|
||||
timeFrame: string;
|
||||
hasSelectedEvents: boolean;
|
||||
eventNames: string[];
|
||||
chartType: string;
|
||||
showXAxis: boolean;
|
||||
}
|
||||
|
||||
export function UmamiEventsContent({
|
||||
series,
|
||||
integrationIds,
|
||||
websiteId,
|
||||
timeFrame,
|
||||
hasSelectedEvents,
|
||||
eventNames,
|
||||
chartType,
|
||||
showXAxis,
|
||||
}: UmamiEventsContentProps) {
|
||||
@@ -27,7 +31,15 @@ export function UmamiEventsContent({
|
||||
const locale = useCurrentIntlLocale();
|
||||
const tickColor = "var(--mantine-color-dimmed)";
|
||||
|
||||
if (!hasSelectedEvents) {
|
||||
const series =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||
umamiQueryOptions,
|
||||
),
|
||||
) ?? [];
|
||||
|
||||
if (eventNames.length === 0) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
|
||||
@@ -205,12 +205,6 @@ const UpdateCard = ({
|
||||
const versionText = isDigestUpdate
|
||||
? t("updateAvailable")
|
||||
: buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion));
|
||||
const versionBadge = versionText ? (
|
||||
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
|
||||
{versionText}
|
||||
</Badge>
|
||||
) : null;
|
||||
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
|
||||
|
||||
return (
|
||||
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}>
|
||||
@@ -219,7 +213,13 @@ const UpdateCard = ({
|
||||
{update.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{showVersionTooltip ? <Tooltip label={fullVersionText}>{versionBadge}</Tooltip> : versionBadge}
|
||||
{versionText && (
|
||||
<Tooltip label={fullVersionText} disabled={isDigestUpdate || versionText === fullVersionText}>
|
||||
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
|
||||
{versionText}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{href && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
|
||||
Reference in new issue
Block a user