Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e578ac70 | ||
|
|
9bfb8f273c | ||
|
|
f3ac40c527 | ||
|
|
bfba3c9215 | ||
|
|
7184bc09c1 | ||
|
|
9bb0c0f811 |
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ const optionMapping: OptionMapping = {
|
||||
useCustomTimezone: () => true,
|
||||
customTimeFormat: () => undefined,
|
||||
customDateFormat: () => undefined,
|
||||
showWeather: () => undefined,
|
||||
weatherLocation: () => undefined,
|
||||
isWeatherFormatFahrenheit: () => undefined,
|
||||
},
|
||||
downloads: {
|
||||
activeTorrentThreshold: (oldOptions) =>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -146,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>
|
||||
@@ -184,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user