Compare commits

..
44 changed files with 934 additions and 254 deletions

No files matched your search

@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of your album pictures from Immich'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
hide_title: true
---
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of your album pictures from Immich",
description: "Shows a slideshow of pictures from an Immich album or your full library",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description: "Select the Immich album to show in the slideshow.",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" },
defaultValue: "",
defaultValue: "All photos",
},
{
name: "Interval in seconds",
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -33,6 +33,9 @@ export default async function AppsPage(props: AppsPageProps) {
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
hidden: !session?.user.permissions.includes("board-modify-all"),
iconProps: {
strokeWidth: 2.5,
},
+2 -1
View File
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
getPaginated: permissionRequiredProcedure
.requiresPermission("board-modify-all")
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
+1 -1
View File
@@ -101,7 +101,7 @@ export const homeRouter = createTRPCRouter({
});
}
if (ctx.session?.user) {
if (ctx.session?.user.permissions.includes("board-modify-all")) {
statistics.push({
titleKey: "app",
subtitleKey: "resources",
+27
View File
@@ -76,6 +76,33 @@ describe("all should return all apps", () => {
});
});
describe("getPaginated should require board modification access", () => {
test("should throw FORBIDDEN without board-modify-all", async () => {
const caller = appRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createDefaultSession(),
});
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
});
test("should return apps with board-modify-all", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: createDefaultSession(["board-modify-all"]),
});
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
totalCount: 1,
items: [{ id: "1" }],
});
});
});
describe("byId should return an app by id", () => {
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
// Arrange
@@ -0,0 +1,45 @@
import { describe, expect, test } from "vitest";
import { BoundedAsyncQueue } from "../bounded-async-queue";
describe("BoundedAsyncQueue", () => {
test("keeps only the newest values when the queue is full", async () => {
const queue = new BoundedAsyncQueue<number>(2);
queue.push(1);
queue.push(2);
queue.push(3);
queue.close();
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
});
test("resolves a pending read as soon as a value arrives", async () => {
const queue = new BoundedAsyncQueue<string>(1);
const next = queue.next();
queue.push("latest");
await expect(next).resolves.toEqual({ value: "latest", done: false });
});
test("resolves a pending read when the producer closes", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
queue.close();
await expect(next).resolves.toEqual({ value: undefined, done: true });
});
test("rejects a pending read when the producer fails", async () => {
const queue = new BoundedAsyncQueue<number>(1);
const next = queue.next();
const error = new Error("upstream failed");
queue.fail(error);
await expect(next).rejects.toBe(error);
});
});
+76 -79
View File
@@ -1,5 +1,4 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,8 +13,10 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
@@ -203,98 +204,94 @@ export const beszelRouter = createTRPCRouter({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
.subscription(async function* ({ ctx, input, signal }) {
const integration = ctx.integrations[0];
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
const controller = new AbortController();
let emittedEventCount = 0;
const stop = () => {
controller.abort();
void queue.return();
};
if (signal?.aborted) return;
signal?.addEventListener("abort", stop, { once: true });
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((candidate) => candidate.id),
systemId: input.systemId,
});
try {
const instance = await createIntegrationAsync(integration);
if (controller.signal.aborted) return;
void (async () => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
);
return;
}
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
} else {
emit.error(
new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
if (typeof instance.subscribeRealtimeMetrics !== "function") {
throw new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
});
emit.error(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
queue.push(event);
},
controller.signal,
);
queue.close();
} catch (error) {
if (controller.signal.aborted) {
queue.close();
return;
}
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.complete();
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
for await (const event of queue) yield event;
} finally {
stop();
signal?.removeEventListener("abort", stop);
logger.debug("Beszel realtime subscription stopped", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
}
}),
});
@@ -0,0 +1,88 @@
type QueueResult<T> = IteratorResult<T, undefined>;
type PendingRead<T> = {
resolve: (result: QueueResult<T>) => void;
reject: (reason?: unknown) => void;
};
/**
* A small async queue for live streams.
*
* Live metrics are snapshots, so retaining every snapshot while a client is
* slow is both unnecessary and dangerous. When the queue is full, discard
* the oldest pending snapshot and keep the newest one.
*/
export class BoundedAsyncQueue<T> implements AsyncIterableIterator<T> {
private readonly values: T[] = [];
private pendingRead: PendingRead<T> | undefined;
private closed = false;
private failed = false;
private failure: unknown;
public constructor(private readonly maxSize: number) {
if (!Number.isInteger(maxSize) || maxSize < 1) {
throw new Error("A bounded async queue requires a positive integer max size");
}
}
public push(value: T): void {
if (this.closed) return;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value, done: false });
return;
}
if (this.values.length >= this.maxSize) this.values.shift();
this.values.push(value);
}
public close(): void {
if (this.closed) return;
this.closed = true;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.resolve({ value: undefined, done: true });
}
}
public fail(error: unknown): void {
if (this.closed) return;
this.closed = true;
this.failed = true;
this.failure = error;
this.values.length = 0;
if (this.pendingRead) {
const pendingRead = this.pendingRead;
this.pendingRead = undefined;
pendingRead.reject(error);
}
}
public next(): Promise<QueueResult<T>> {
if (this.values.length > 0) {
return Promise.resolve({ value: this.values.shift() as T, done: false });
}
if (this.failed) return Promise.reject(this.failure);
if (this.closed) return Promise.resolve({ value: undefined, done: true });
return new Promise<QueueResult<T>>((resolve, reject) => {
this.pendingRead = { resolve, reject };
});
}
public return(): Promise<QueueResult<T>> {
this.values.length = 0;
this.close();
return Promise.resolve({ value: undefined, done: true });
}
public [Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string(),
albumId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
@@ -67,12 +67,14 @@ export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
const agent =
options?.dispatcher ??
(await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
));
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
@@ -84,7 +86,7 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, {
...fetchOptions,
dispatcher: agent,
@@ -1,6 +1,7 @@
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { Response as UndiciResponse } from "undici";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -357,19 +358,41 @@ export class BeszelIntegration extends Integration {
let emittedSnapshotCount = 0;
while (!signal.aborted) {
let streamResponse: UndiciResponse | undefined;
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
const closeStreamAsync = async () => {
const response = streamResponse;
streamResponse = undefined;
const agent = streamAgent;
streamAgent = undefined;
if (response?.body) await response.body.cancel().catch(() => {});
if (agent) await agent.close().catch(() => {});
};
try {
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal,
bodyTimeout: 0,
});
const openStream = async () => {
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
try {
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal,
bodyTimeout: 0,
dispatcher: streamAgent,
});
streamResponse = response;
return response;
} catch (error) {
await closeStreamAsync();
throw error;
}
};
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
@@ -378,6 +401,7 @@ export class BeszelIntegration extends Integration {
});
let response = await openStream();
if (response.status === 401) {
await closeStreamAsync();
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
@@ -402,14 +426,21 @@ export class BeszelIntegration extends Integration {
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
dispatcher: streamAgent,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
try {
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
await subscribeResponse.arrayBuffer();
} finally {
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
}
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
@@ -461,10 +492,12 @@ export class BeszelIntegration extends Integration {
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
await closeStreamAsync();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
await closeStreamAsync();
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
@@ -10,6 +10,7 @@ vi.hoisted(() => {
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
@@ -32,7 +33,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
@@ -153,6 +154,7 @@ describe("subscribeRealtimeMetrics", () => {
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
expect(closeAgent).toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
@@ -1,11 +1,14 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -43,6 +46,7 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -63,38 +67,36 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
assets: await this.createAssetsAsync(albumAssets),
id: album.id,
};
}
@@ -136,6 +138,32 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
@@ -65,6 +65,9 @@ const optionMapping: OptionMapping = {
useCustomTimezone: () => true,
customTimeFormat: () => undefined,
customDateFormat: () => undefined,
showWeather: () => undefined,
weatherLocation: () => undefined,
isWeatherFormatFahrenheit: () => undefined,
},
downloads: {
activeTorrentThreshold: (oldOptions) =>
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
{ albumId?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
@@ -16,4 +16,6 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
users: await integrationInstance.getUsersAsync(),
};
},
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
});
@@ -787,11 +787,34 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
};
// LSIO's /api/v1/images only exposes the single latest version, so a version
// filter can never surface an older release (#4351). When the image has a GitHub
// repository, delegate to the GitHub release provider to fetch the real release
// history and apply the filter, while keeping LSIO's own image metadata. Any
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return {
success: false,
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
};
}
if (!regex || regex.test(release.version)) {
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
},
};
}
// LSIO's /api/v1/images only exposes the single latest version, so a filter
// that does not match it cannot surface an older release (#4351). Delegate to
// the image's GitHub repository to fetch the real release history while
// keeping LSIO's own image metadata.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
@@ -808,37 +831,14 @@ const getLinuxServerIOReleaseAsync = async (
},
};
}
}
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return {
success: false,
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
};
}
if (regex && !regex.test(release.version)) {
return {
success: false,
error: {
code: "noMatchingVersion",
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
},
};
return githubResult;
}
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
success: false,
error: {
code: "noMatchingVersion",
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
},
};
};
@@ -126,6 +126,128 @@ describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => {
});
});
describe("getLatestMatchingReleaseAsync for LinuxServer.io", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test("uses the LinuxServer.io latest release without fetching GitHub history", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "lscr.io/linuxserver/tautulli:latest",
});
expect(result).toEqual({
success: true,
data: {
latestRelease: "v2.17.2-ls237",
latestReleaseAt: new Date("2026-07-17T00:25:42.000Z"),
releaseDescription: "Rebase to Alpine 3.24.",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
isArchived: false,
createdAt: new Date("2015-08-10T00:00:00.000Z"),
starsCount: 934,
},
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("fetches GitHub history when a version filter requires it", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
if (url.pathname === "/repos/linuxserver/docker-tautulli/releases") {
return createJsonResponse([
{
tag_name: "v2.16.0-ls200",
published_at: "2026-01-10T12:00:00.000Z",
html_url: "https://github.com/linuxserver/docker-tautulli/releases/tag/v2.16.0-ls200",
body: "Older supported image",
prerelease: false,
},
]);
}
if (url.pathname === "/repos/linuxserver/docker-tautulli") {
return createJsonResponse({
html_url: "https://github.com/linuxserver/docker-tautulli",
description: "GitHub description",
fork: false,
archived: false,
created_at: "2015-08-10T00:00:00.000Z",
stargazers_count: 934,
open_issues_count: 2,
forks_count: 80,
});
}
return createJsonResponse({ message: "Not Found" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({
latestRelease: "v2.16.0-ls200",
projectUrl: "https://github.com/linuxserver/docker-tautulli",
projectDescription: "Tautulli image",
}),
});
expect(mockedFetch).toHaveBeenCalledTimes(3);
});
test("uses the LinuxServer.io latest release when it matches the version filter", async () => {
mockedFetch.mockResolvedValueOnce(createLinuxServerResponse());
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.17\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: true,
data: expect.objectContaining({ latestRelease: "v2.17.2-ls237" }),
});
expect(mockedFetch).toHaveBeenCalledOnce();
expectUrl(0, "https://api.linuxserver.io/api/v1/images");
});
test("returns the GitHub error when filtered release history cannot be fetched", async () => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse();
return createJsonResponse({ message: "GitHub unavailable" }, { status: 404, statusText: "Not Found" });
});
const result = await getLatestMatchingReleaseAsync({
id: "tautulli",
provider: "linuxServerIO",
identifier: "linuxserver/tautulli",
versionRegex: "^v2\\.16\\..+-ls[0-9]+$",
});
expect(result).toEqual({
success: false,
error: expect.objectContaining({ code: "unexpected", message: expect.stringContaining("GitHub unavailable") }),
});
expect(mockedFetch.mock.calls.length).toBeGreaterThan(1);
});
});
interface MockGhcrResponsesInput {
registryOrigin?: string;
repositoryName: string;
@@ -185,6 +307,27 @@ const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) =>
},
});
const createLinuxServerResponse = () =>
createJsonResponse({
data: {
repositories: {
linuxserver: [
{
name: "tautulli",
initial_date: "2015-08-10",
github_url: "https://github.com/linuxserver/docker-tautulli",
description: "Tautulli image",
version: "v2.17.2-ls237",
version_timestamp: "2026-07-17 00:25:42+00:00",
stars: 934,
deprecated: false,
changelog: [{ date: "2026-07-05", desc: "Rebase to Alpine 3.24." }],
},
],
},
},
});
const expectUrl = (callIndex: number, expectedUrl: string) => {
expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl);
};
+4 -1
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
@@ -66,5 +68,6 @@ export interface Weather {
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
@@ -17,6 +17,10 @@ export const appIntegrationBoardMode = {
return groups;
}
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
return groups.concat(
session.user.permissions.includes("board-modify-all")
? [appsSearchGroup, integrationsSearchGroup]
: [integrationsSearchGroup],
);
},
} satisfies SearchMode;
@@ -87,7 +87,8 @@ export const globalCommandGroup = createGroup<Command>({
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
hidden:
!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
@@ -76,7 +76,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconBox,
path: "/manage/apps",
name: t("manageApp.label"),
hidden: !session,
hidden: !session?.user.permissions.includes("board-modify-all"),
},
{
icon: IconPlug,
+16 -5
View File
@@ -1905,6 +1905,16 @@
"customDateFormat": {
"label": "Custom date format",
"description": "Use ISO 8601 to format date (this will override other options)"
},
"showWeather": {
"label": "Show weather",
"description": "Display a small animated weather icon and temperature in the corner of the card"
},
"weatherLocation": {
"label": "Weather location"
},
"isWeatherFormatFahrenheit": {
"label": "Use Fahrenheit"
}
}
},
@@ -2257,7 +2267,8 @@
"sunrise": "Sunrise",
"sunset": "Sunset",
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
"humidity": "Humidity: {humidity}%"
},
"kind": {
"clear": "Clear",
@@ -3006,10 +3017,11 @@
},
"immich-albumCarousel": {
"name": "Immich Album",
"allPhotos": "All photos",
"option": {
"albumId": {
"label": "Album",
"description": "Select the Immich album to show in the slideshow."
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
},
"rotationIntervalSeconds": {
"label": "Interval in seconds",
@@ -3024,9 +3036,8 @@
"description": "Show photos in a random order instead of always starting with the same album order."
}
},
"description": "Slideshow of pictures in your Immich album",
"noAlbumSelected": "No album selected, edit widget and choose an album",
"noPhotos": "No photos in this album"
"description": "Slideshow of pictures from an Immich album or your full library",
"noPhotos": "No photos found"
},
"downloads": {
"name": "Download Client",
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -10,10 +10,22 @@ import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homar
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
const subscribeToVisibility = (onChange: () => void) => {
if (typeof document === "undefined") return () => {};
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
};
const getPageVisibility = () => typeof document === "undefined" || document.visibilityState === "visible";
const usePageVisible = () => useSyncExternalStore(subscribeToVisibility, getPageVisibility, () => true);
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
const [containerStats, setContainerStats] = useState<BeszelContainerStatsRecord[]>([]);
const [error, setError] = useState<Error | null>(null);
const pageVisible = usePageVisible();
const subscriptionEnabled = enabled && pageVisible && systemId !== "";
// Append to buffer, trimming to MAX_BUFFER
const appendSystemStats = useCallback((record: BeszelSystemStatsRecord) => {
@@ -31,7 +43,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
subscriptionEnabled ? { integrationIds, systemId } : skipToken,
{
onData(event) {
setError(null);
@@ -49,7 +61,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
const currentKey = `${integrationIds.join(",")}:${systemId}:${subscriptionEnabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
+56 -13
View File
@@ -1,4 +1,6 @@
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
@@ -11,9 +13,14 @@ interface CalendarDayProps {
disabled: boolean;
rootWidth: number;
rootHeight: number;
onOpen: (close: () => void) => void;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
@@ -22,19 +29,39 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return (
<HoverCard
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{ transition: "pop" }}
openDelay={350}
closeDelay={400}
transitionProps={{
transition: "pop",
}}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled}
>
<HoverCard.Target>
<Popover.Target>
<Container
h="100%"
w="100%"
@@ -42,23 +69,38 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
ref={ref}
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
{!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
</Container>
</HoverCard.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
</Popover.Target>
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} />
</HoverCard.Dropdown>
</HoverCard>
</Popover.Dropdown>
</Popover>
);
};
@@ -69,6 +111,7 @@ interface NotificationIndicatorProps {
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
/* position bottom is lower when small to not be on top of number*/
return (
<Flex
w="75%"
+7 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -65,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return (
<Calendar
@@ -141,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
onOpen={onDayOpen}
/>
);
}}
+57 -19
View File
@@ -1,13 +1,16 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Stack, Text, Title } from "@mantine/core";
import { Box, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
@@ -23,30 +26,65 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
const time = useCurrentTime(options);
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
{options.customTitleToggle && (
<Text className="clock-customTitle-text" size={sizing} ta="center">
{options.customTitle}
</Text>
<Box className="clock-widget-container" h="100%" pos="relative">
{showWeatherCorner && (
<ClockWeatherCorner
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
isFahrenheit={options.isWeatherFormatFahrenheit}
/>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{options.customTimeFormat
? dayjs(time).tz(timezone).format(customTimeFormat)
: dayjs(time).tz(timezone).format(timeFormat)}
</Title>
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{options.customDateFormat
? dayjs(time).tz(timezone).format(customDateFormat)
: dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}
</Stack>
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
{options.customTitleToggle && (
<Text className="clock-customTitle-text" size={sizing} ta="center">
{options.customTitle}
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{options.customTimeFormat
? dayjs(time).tz(timezone).format(customTimeFormat)
: dayjs(time).tz(timezone).format(timeFormat)}
</Title>
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{options.customDateFormat
? dayjs(time).tz(timezone).format(customDateFormat)
: dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}
</Stack>
</Box>
);
}
interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
isFahrenheit: boolean;
}
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
if (!weather) return null;
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
return (
<Stack className="clock-weather-corner" pos="absolute" top={4} left={4} gap={0} align="center">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
</Stack>
);
};
interface UseCurrentTimeProps {
showSeconds: boolean;
}
+20
View File
@@ -55,6 +55,20 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
defaultValue: "",
withDescription: true,
}),
showWeather: factory.switch({
defaultValue: false,
withDescription: true,
}),
weatherLocation: factory.location({
defaultValue: {
name: "Brisbane",
latitude: -27.4698,
longitude: 153.0251,
},
}),
isWeatherFormatFahrenheit: factory.switch({
defaultValue: false,
}),
}),
{
customTitle: {
@@ -66,6 +80,12 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
dateFormat: {
shouldHide: (options) => !options.showDate,
},
weatherLocation: {
shouldHide: (options) => !options.showWeather,
},
isWeatherFormatFahrenheit: {
shouldHide: (options) => !options.showWeather,
},
},
);
},
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
@@ -21,9 +22,9 @@ export default function ImmichAlbumCarouselWidget({
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
},
{ enabled: Boolean(options.albumId) },
{ enabled: integrationIds.length > 0 },
);
const photoAssets = useMemo(() => {
@@ -35,10 +36,6 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
@@ -117,20 +114,6 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho
);
}
function NoAlbumSelected() {
const t = useI18n();
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertCircle size={32} />
<Text size="sm" fw={500}>
{t("widget.immich-albumCarousel.noAlbumSelected")}
</Text>
</Stack>
</Center>
);
}
function NoPhotosInAlbum() {
const t = useI18n();
return (
@@ -0,0 +1 @@
export const ALL_PHOTOS_ALBUM_ID = "all";
@@ -2,9 +2,11 @@ import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
@@ -15,9 +17,11 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
@@ -26,7 +30,14 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({
@@ -66,10 +66,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isPaused = currentlyPlaying.playback.state === "paused";
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const { positionMs, durationMs } = currentlyPlaying.playback;
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
+14 -10
View File
@@ -29,6 +29,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
import { getReleasesQueryStaleTimeMs } from "./query-options";
const formatRelativeDate = (value: string): string => {
const isMonths = /\d+m/g.test(value);
@@ -89,16 +90,19 @@ export default function ReleasesWidget({ options, itemId }: WidgetComponentProps
const queryResults = clientApi.useQueries((trpc) =>
batchedRepositories.flatMap(({ provider, repositories }) =>
trpc.widget.releases.getLatest({
itemId,
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
}),
trpc.widget.releases.getLatest(
{
itemId,
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
},
{ staleTime: getReleasesQueryStaleTimeMs },
),
),
);
@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import { getReleasesQueryStaleTimeMs } from "./query-options";
describe("getReleasesQueryStaleTimeMs", () => {
test("keeps successful release batches fresh for four hours", () => {
const staleTime = getReleasesQueryStaleTimeMs({
state: { data: [{ success: true }, { success: true }] },
});
expect(staleTime).toBe(4 * 60 * 60 * 1000);
});
test("keeps a batch stale when a provider returned an error", () => {
const staleTime = getReleasesQueryStaleTimeMs({
state: { data: [{ success: true }, { success: false }] },
});
expect(staleTime).toBe(0);
});
test.each([undefined, [], [{ unexpected: true }]])("keeps missing or invalid data stale", (data) => {
expect(getReleasesQueryStaleTimeMs({ state: { data } })).toBe(0);
});
});
@@ -0,0 +1,17 @@
export const releasesQuerySuccessfulStaleTimeMs = 4 * 60 * 60 * 1000;
interface QueryWithData {
state: {
data: unknown;
};
}
export const getReleasesQueryStaleTimeMs = ({ state }: QueryWithData) => {
if (!Array.isArray(state.data) || state.data.length === 0) return 0;
const allRepositoriesSucceeded = state.data.every(
(result) => typeof result === "object" && result !== null && "success" in result && result.success === true,
);
return allRepositoriesSucceeded ? releasesQuerySuccessfulStaleTimeMs : 0;
};
@@ -0,0 +1,28 @@
import "../widgets-common.css";
import { WeatherIcon } from "./icon";
interface AnimatedWeatherIconProps {
code: number;
size?: string | number;
}
const getAnimationClass = (code: number): string => {
if (code === 0) return "weather-anim-sun";
if ([1, 2, 3].includes(code)) return "weather-anim-cloud";
if ([45, 48].includes(code)) return "weather-anim-fog";
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return "weather-anim-rain";
if ([71, 73, 75, 77, 85, 86].includes(code)) return "weather-anim-snow";
if ([95, 96, 99].includes(code)) return "weather-anim-storm";
return "";
};
export const AnimatedWeatherIcon = ({ code, size = 26 }: AnimatedWeatherIconProps) => {
return (
<span className={`weather-anim-wrapper ${getAnimationClass(code)}`}>
<WeatherIcon code={code} size={size} />
</span>
);
};
+13 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -12,7 +12,8 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { WeatherDescription, WeatherIcon } from "./icon";
import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
const input = {
@@ -55,7 +56,7 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
<HoverCard>
<HoverCard.Target>
<Box>
<WeatherIcon size={30} code={weather.current.weathercode} />
<AnimatedWeatherIcon size={30} code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
@@ -87,6 +88,12 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text>
</Group>
)}
{weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size={16} />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group>
)}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<IconArrowUpRight size={16} />
@@ -140,7 +147,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
<HoverCard>
<HoverCard.Target>
<Box>
<WeatherIcon size={16} code={weather.current.weathercode} />
<AnimatedWeatherIcon size={16} code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
@@ -178,7 +185,7 @@ function Forecast({ weather, options }: WeatherProps) {
align="center"
>
<Text fz="xl">{dayjs(dayWeather.time).format("dd")}</Text>
<WeatherIcon size={16} code={dayWeather.weatherCode} />
<AnimatedWeatherIcon size={16} code={dayWeather.weatherCode} />
<Text fz={16}>
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
@@ -204,6 +211,7 @@ function Forecast({ weather, options }: WeatherProps) {
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
maxWindSpeed={dayWeather.maxWindSpeed}
maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/>
</HoverCard.Dropdown>
</HoverCard>
+6
View File
@@ -5,6 +5,7 @@ import {
IconCloudRain,
IconCloudSnow,
IconCloudStorm,
IconDroplets,
IconMoon,
IconQuestionMark,
IconSnowflake,
@@ -51,6 +52,7 @@ interface WeatherDescriptionProps {
sunset?: string;
maxWindSpeed?: number;
maxWindGusts?: number;
humidity?: number;
}
/**
@@ -78,6 +80,7 @@ export const WeatherDescription = ({
sunset,
maxWindSpeed,
maxWindGusts,
humidity,
}: WeatherDescriptionProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
@@ -97,6 +100,9 @@ export const WeatherDescription = ({
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && (
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
)}
{maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindSpeed", {
+80
View File
@@ -25,3 +25,83 @@
.text-flash {
animation: glow 1s ease-in-out;
}
/*Animated weather icons, used by clock and weather widgets*/
.weather-anim-wrapper {
display: inline-flex;
}
@keyframes weather-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes weather-drift {
0%,
100% {
transform: translateX(-2px);
}
50% {
transform: translateX(2px);
}
}
@keyframes weather-fall {
0% {
transform: translateY(-2px);
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
transform: translateY(2px);
opacity: 0.4;
}
}
@keyframes weather-flicker {
0%,
90%,
100% {
opacity: 1;
}
92%,
96% {
opacity: 0.4;
}
}
.weather-anim-sun {
animation: weather-spin 16s linear infinite;
transform-origin: center;
}
.weather-anim-cloud,
.weather-anim-fog {
animation: weather-drift 4s ease-in-out infinite;
}
.weather-anim-rain,
.weather-anim-snow {
animation: weather-fall 1.6s ease-in-out infinite;
}
.weather-anim-storm {
animation: weather-flicker 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.weather-anim-sun,
.weather-anim-cloud,
.weather-anim-fog,
.weather-anim-rain,
.weather-anim-snow,
.weather-anim-storm {
animation: none;
}
}